diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d9c6e51..51864f7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,10 @@ on: branches: [main] concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Keep hosted evidence attached to the exact PR head being evaluated. A + # checklist/documentation push must not cancel the preceding semantic run. + group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.sha }} + cancel-in-progress: false env: CMAKE_BUILD_TYPE: Release @@ -31,7 +33,7 @@ jobs: python-version: "3.12" - name: Install linters and policy dependencies - run: pip install black ruff pytest + run: pip install black ruff pytest numpy - name: Check Python formatting (Black) run: black --check --diff --target-version py312 python/ tests/python/ scripts/ @@ -64,6 +66,9 @@ jobs: - name: Enforce validation exception budget run: python -m tests.validation.exception_ledger --max-exceptions 1 + - name: Run CI contract tests + run: python -m pytest tests/test_ci_contract.py -q + # ═══════════════════════════════════════════════════════════════════════════ # C++ Build & Test (BNG engine with embedded NFsim library) # ═══════════════════════════════════════════════════════════════════════════ @@ -188,7 +193,7 @@ jobs: # ═══════════════════════════════════════════════════════════════════════════ validation: - name: Validation (${{ matrix.os }}) + name: Validation (supported reference subset, ${{ matrix.os }}) needs: cpp-build runs-on: ${{ matrix.os }} strategy: @@ -222,12 +227,13 @@ jobs: if: "!startsWith(matrix.os, 'windows')" run: chmod +x build/cpp/bng_cpp* - - name: Run validation against reference .net files + - name: Run supported validation against reference .net files shell: bash run: | BNG=build/cpp/bng_cpp${{ startsWith(matrix.os, 'windows') && '.exe' || '' }} - SKIP="--skip michment_cont,test_sbml_flat,test_sbml_structured,Repressilator,SHP2_base_model" - python scripts/validate.py --bng-cpp "$BNG" $SKIP --verbose + python scripts/validate.py --bng-cpp "$BNG" --strict-references \ + --skip-file tests/validation/reference_exclusions.json \ + --skip-profile pull_request --verbose # ═══════════════════════════════════════════════════════════════════════════ # BNGL corpus parse inventory (not an NFsim simulation check) @@ -297,7 +303,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-22.04, macos-14, windows-2022] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 @@ -319,7 +325,7 @@ jobs: uses: ilammy/msvc-dev-cmd@v1 - name: Install Python build dependencies - run: pip install scikit-build-core pybind11 numpy pytest pytest-cov cement colorlog pyparsing pylru pandas matplotlib python-libsbml pyyaml lxml networkx sympy seaborn xmltodict + run: pip install scikit-build-core pybind11 numpy click pytest pytest-cov cement colorlog pyparsing pylru pandas matplotlib python-libsbml pyyaml lxml networkx sympy seaborn xmltodict - name: Build and install package (Windows) if: startsWith(matrix.os, 'windows') @@ -336,10 +342,26 @@ jobs: env: CMAKE_ARGS: "-DBUILD_TESTS=OFF -DBUILD_CLI=OFF" + - name: Seed isolated Matplotlib font cache (Unix) + if: matrix.os == 'ubuntu-22.04' && (matrix.python-version == '3.9' || matrix.python-version == '3.10') + run: python scripts/prepare_matplotlib_cache.py + env: + MPLBACKEND: Agg + MPLCONFIGDIR: ${{ runner.temp }}/matplotlib + - name: Run Python tests - run: pytest tests/python/ -v --tb=short --cov=bionetgen --cov-report=xml + run: pytest tests/python/test_sbml_import.py -v --tb=short env: SYMPY_USE_GMPY: "0" + MPLBACKEND: Agg + MPLCONFIGDIR: ${{ runner.temp }}/matplotlib + + - name: Run Python tests + run: pytest tests/python/ -v --tb=short --cov=bionetgen --cov-report=xml --ignore=tests/python/test_sbml_import.py + env: + SYMPY_USE_GMPY: "0" + MPLBACKEND: Agg + MPLCONFIGDIR: ${{ runner.temp }}/matplotlib - name: Upload coverage if: matrix.os == 'ubuntu-22.04' && matrix.python-version == '3.12' @@ -406,6 +428,50 @@ jobs: env: SYMPY_USE_GMPY: "0" + # ═══════════════════════════════════════════════════════════════════════════ + # Source Distribution Installation Smoke Test + # ═══════════════════════════════════════════════════════════════════════════ + + package-smoke: + name: Package Smoke (sdist) + needs: [python-test] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install packaging and native build dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake ninja-build + python -m pip install build + + - name: Build source distribution + run: python -m build --sdist + + - name: Install source distribution in clean environment + shell: bash + run: | + set -eu + python -m venv "$RUNNER_TEMP/bng3-package-smoke" + "$RUNNER_TEMP/bng3-package-smoke/bin/python" -m pip install --upgrade pip + "$RUNNER_TEMP/bng3-package-smoke/bin/python" -m pip install numpy click + "$RUNNER_TEMP/bng3-package-smoke/bin/python" -m pip install --no-deps dist/*.tar.gz + env: + CMAKE_ARGS: "-DBUILD_TESTS=OFF -DBUILD_CLI=OFF" + + - name: Smoke installed package + shell: bash + run: | + set -eu + "$RUNNER_TEMP/bng3-package-smoke/bin/python" -c \ + "import bionetgen; import bionetgen._bionetgen_cpp" + "$RUNNER_TEMP/bng3-package-smoke/bin/bionetgen" --version + "$RUNNER_TEMP/bng3-package-smoke/bin/bionetgen" --help + # ═══════════════════════════════════════════════════════════════════════════ # Wheel Building (binary distribution) # ═══════════════════════════════════════════════════════════════════════════ @@ -437,10 +503,10 @@ jobs: - name: Build wheels run: cibuildwheel --output-dir wheelhouse env: - CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-*" + CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-* cp314-*" CIBW_SKIP: "*-musllinux_* *-manylinux_i686 *-win32" CIBW_BUILD_VERBOSITY: 1 - CIBW_TEST_REQUIRES: pytest numpy + CIBW_TEST_REQUIRES: pytest numpy click CIBW_TEST_COMMAND: pytest {project}/tests/python/test_cpp_backend.py -v --tb=short CIBW_ENVIRONMENT: CMAKE_ARGS="-DBUILD_TESTS=OFF -DBUILD_CLI=OFF" # macOS: build universal2 wheels diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..4d147f7a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,64 @@ +name: CodeQL + +on: + push: + branches: [main, develop] + pull_request: + branches: [main] + schedule: + - cron: "17 3 * * 1" + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + include: + - language: cpp + build-mode: manual + - language: python + build-mode: none + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Install C++ build dependencies + if: matrix.language == 'cpp' + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build + + - name: Configure C++ project + if: matrix.language == 'cpp' + run: >- + cmake -B build -G Ninja + -DCMAKE_BUILD_TYPE=Release + -DBUILD_PYTHON_BINDINGS=OFF + -DBUILD_CLI=OFF + -DBUILD_NFSIM_CLI=OFF + -DBUILD_TESTS=OFF + + - name: Build C++ project + if: matrix.language == 'cpp' + run: cmake --build build --parallel 2 + + - name: Analyze + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 209a938c..e921e5d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,9 +107,9 @@ jobs: - name: Build wheels run: cibuildwheel --output-dir wheelhouse env: - CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-*" + CIBW_BUILD: "cp39-* cp310-* cp311-* cp312-* cp313-* cp314-*" CIBW_SKIP: "*-musllinux_* *-manylinux_i686 *-win32" - CIBW_TEST_REQUIRES: pytest numpy + CIBW_TEST_REQUIRES: pytest numpy click CIBW_TEST_COMMAND: pytest {project}/tests/python/test_cpp_backend.py -v --tb=short CIBW_ENVIRONMENT: CMAKE_ARGS="-DBUILD_TESTS=OFF -DBUILD_CLI=OFF" CIBW_ARCHS_MACOS: "arm64 x86_64" diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index 981ed115..c8f89f00 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -10,11 +10,11 @@ env: jobs: # ═══════════════════════════════════════════════════════════════════════════ - # Full BNG Network Generation Validation + # Supported BNG Network Generation Validation # ═══════════════════════════════════════════════════════════════════════════ bng-validation: - name: BNG Validation (${{ matrix.os }}) + name: BNG Validation (supported reference subset, ${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -50,16 +50,17 @@ jobs: -DBUILD_TESTS=OFF cmake --build build --config Release - - name: Run full validation + - name: Run supported validation shell: bash run: | - SKIP="--skip michment_cont,test_sbml_flat,test_sbml_structured,Repressilator,SHP2_base_model,Motivating_example_cBNGL,blbr" if [[ "${{ matrix.os }}" == *windows* ]]; then BNG=build/cpp/bng_cpp.exe else BNG=build/cpp/bng_cpp fi - python scripts/validate.py --bng-cpp "$BNG" $SKIP --verbose + python scripts/validate.py --bng-cpp "$BNG" --strict-references \ + --skip-file tests/validation/reference_exclusions.json \ + --skip-profile weekly --verbose # ═══════════════════════════════════════════════════════════════════════════ # NFsim execution smoke test (not a statistical parity check) @@ -211,7 +212,7 @@ jobs: - name: Run cross-validation run: | - set -e + set -euo pipefail BNG_CPP=build/cpp/bng_cpp BNG_PERL=legacy/perl/BNG2.pl MODELS_DIR=tests/validation/Validate @@ -223,16 +224,18 @@ jobs: PASS=0 FAIL=0 - SKIP=0 + TOTAL=0 for bngl in "$MODELS_DIR"/*.bngl; do model=$(basename "$bngl" .bngl) + TOTAL=$((TOTAL + 1)) tmpdir=$(mktemp -d) # Run C++ engine cp "$bngl" "$tmpdir/" if ! timeout 30 "$BNG_CPP" "$tmpdir/$model.bngl" > /dev/null 2>&1; then - SKIP=$((SKIP + 1)) + echo "FAIL $model: C++ execution failed" + FAIL=$((FAIL + 1)) rm -rf "$tmpdir" continue fi @@ -242,7 +245,8 @@ jobs: perl_tmpdir=$(mktemp -d) cp "$bngl" "$perl_tmpdir/" if ! timeout 60 perl "$BNG_PERL" "$perl_tmpdir/$model.bngl" > /dev/null 2>&1; then - SKIP=$((SKIP + 1)) + echo "FAIL $model: Perl execution failed" + FAIL=$((FAIL + 1)) rm -rf "$tmpdir" "$perl_tmpdir" continue fi @@ -260,7 +264,8 @@ jobs: FAIL=$((FAIL + 1)) fi else - SKIP=$((SKIP + 1)) + echo "FAIL $model: one or both .net outputs are missing" + FAIL=$((FAIL + 1)) fi rm -rf "$tmpdir" "$perl_tmpdir" @@ -269,11 +274,11 @@ jobs: { echo "### C++ vs Perl Cross-Validation" echo "" - echo "| Passed | Failed | Skipped |" + echo "| Total | Passed | Failed |" echo "| ---: | ---: | ---: |" - echo "| $PASS | $FAIL | $SKIP |" + echo "| $TOTAL | $PASS | $FAIL |" } | tee -a "$GITHUB_STEP_SUMMARY" - if [ "$PASS" -eq 0 ] || [ "$FAIL" -gt 0 ]; then + if [ "$TOTAL" -eq 0 ] || [ "$FAIL" -gt 0 ]; then exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index 7c9c9d50..2edf36c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,34 +1,116 @@ # AGENTS.md -Build/test/conventions for agents working on BNG3. Terse on purpose. +Build/test/conventions for agents working on BNG3. Terse on purpose. The +authoritative convergence exit gate is +[`docs/BNG3_CONVERGENCE_DONE_CHECKLIST.md`](docs/BNG3_CONVERGENCE_DONE_CHECKLIST.md); +this file gives working rules, not a completion claim. ## What BNG3 is Monorepo merging three tools into one in-process platform: `cpp/` (C++ engine, = bionetgen-master/src + embedded NFsim + pybind11), -`python/bionetgen/` (unified Python API + atomizer; legacy modules being removed), +`python/bionetgen/` (unified Python API + modern atomizer; legacy modules remain +until their deletion gate passes), `legacy/perl/` (BNG2 Perl, kept only as validation oracle). ## Build ```bash cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -cmake --build build # produces build/bng_cpp and the _bionetgen_cpp extension -pip install -e . # scikit-build-core compiles the extension -cmake -B build -DBUILD_NFSIM_CLI=ON # also build the native NFsim oracle (for -m nf) +cmake --build build # bng_cpp and, when enabled, bindings +pip install -e . # scikit-build-core package build +cmake -B build -DBUILD_NFSIM_CLI=ON # BNG3 standalone NFsim CLI/smoke executable ``` -Deps fetched via CMake FetchContent (ANTLR4 4.13.1, SUNDIALS 7.6.0, Catch2, pybind11; exprtk until WO-3b). Network required for a clean build. +Dependencies are fetched by CMake FetchContent (ANTLR4 4.13.2, SUNDIALS +7.6.0, Catch2, pybind11, and ExprTk while the expression work order remains +open). A clean build needs network access or a populated dependency cache. ## Test ```bash -ctest --test-dir build # C++ unit tests (Catch2) -pytest tests/validation -m smoke # Tier-S parity, every commit -pytest tests/validation -m "parity and not slow" # full corpus -pytest tests/validation -m nf --bng-cpp build/bng_cpp -python scripts/regen_golden.py --tier p # rebuild golden oracles (reviewed, committed) +ctest --test-dir build --output-on-failure +PYTHONPATH=python:build/cpp python -m pytest -q tests/python +PYTHONPATH=python:build/cpp python -m pytest -q \ + -c tests/validation/pytest.ini tests/validation -m smoke +NFSIM_BIN=/absolute/path/to/pinned/native/NFsim \ + PYTHONPATH=python:build/cpp python -m pytest tests/validation -m nf \ + --bng-cpp build/cpp/bng_cpp ``` -Engine discovery for the harness: `--bng-cpp PATH` / `BNG_CPP`; the Python API is `import bionetgen`. +Engine discovery for the validation harness is `--bng-cpp PATH` or `BNG_CPP`. +The local development build places executables and the extension under +`build/cpp/`. Run the full validation and golden-generation workflows only +with the pinned oracles and provenance required by the checklist; never make +them pass by widening tolerances or hiding skips. The Python API is +`import bionetgen`. -## The rule that matters -No master function lands until its validation gate is green. Gates live in `tests/validation/` and compare against Perl (`.net`, ODE) and native NFsim (network-free). `blbr` is the sole strict net-parity exception until WO-1a; `Motivating_example_cBNGL` must pass after rate normalization. Do not broaden the exception ledger to hide a new mismatch. +## The rules that matter + +- No semantic master function lands until its source-derived tests and the + dependent validation gate are green. Gates live in `tests/validation/` and + must compare against independent BNG2 Perl and native NFsim artifacts when + they claim parity. +- A source-derived fixture for an unsupported capability may land only as an + explicitly named rejection contract test while its implementation is open; + a red acceptance test must not remain on a public checkpoint. +- A local green suite, parse inventory, or BNG3-generated output is not + independent parity evidence. Missing oracles and validators remain visible + failures/skips with an owner and expiry in the exception ledger. +- `NFSIM_BIN` must name an independently built native NFsim binary from the + pinned pre-convergence source. The embedded BNG3 `NFsim` target is a smoke + executable only; the validation harness must not silently substitute it for + an absent or invalid oracle path. +- Keep the XML NFsim path as a temporary comparator. The default NFsim route + is the direct AST adapter; XML compatibility requires an explicit + `BNG_NFSIM_ALLOW_XML_FALLBACK=1`, and `BNG_NFSIM_FORCE_XML=1` selects the + shadow path. Do not retire XML until the checklist's three-way Tier-NF gate + passes. +- The direct NFsim API uses the native-compatible endpoint-inclusive + `stepTo` form only for its final output checkpoint; the ordinary one-argument + `stepTo` contract remains exclusive for intermediate callers. Keep the + source-derived fixed-seed `motor`/`tlbr` endpoint test green. +- Species-observable maintenance must use semantic `add`/`subtract` so + dependent functional propensities refresh; reserve `straightAdd`/ + `straightSubtract` for count-only initialization or rebuild paths. Keep the + source-derived Issue86 rate-refresh test green. +- NF absolute `t_start` is the simulation clock origin: set it before + `prepareForSimulation()`, pass `t_end - t_start` to duration-based NFsim + loops, keep direct-API/action sample times within `[t_start, t_end]`, and + make `equilibrate(duration)` advance from then restore the absolute clock. + Keep the source-derived Issue78 direct-API/action and equilibrate tests + green. +- Energy-function ports must be source-anchored to the accepted + `akutuva21/nfsim` energy-evaluation cutoff: PR #475, merge + `6690fda5d9e053df822d0248ebae185f5caca82a`, source commit + `3b046fc1b9f76719d92be22279b24992cdae7c35`. Audit later fork-head changes + separately; do not bulk-merge unrelated PRs. Land ports tests-first. The + compact `EnergyBindingContext`/`EnergyRxnClass` path is only for proven + factorized contexts; retain materialized expansion as the compatibility + fallback until broader energy parity, provenance, and direct-NFsim gates + pass. +- Release builds default `NFSIM_ENABLE_LTO=ON`; CMake must probe IPO support + before applying it to embedded NFsim and its consumers. Keep the explicit + ON/OFF contract green, and do not treat LTO build success as benchmark or + parity evidence. +- Non-main `akutuva21/bionetgen` branches are source inputs, not merge bases: + inspect exact branch/PR tips and diffs, port relevant behavior with + source-derived tests first, and classify each source commit as equivalent, + superseded, non-applicable, pending, or blocked in the convergence + checklist. Do not bulk-merge generated benchmark/docs/`.jules` artifacts or + parallel branch stacks without a capability-level decision. +- The convergence session is single-agent: do not delegate implementation or + validation work. Use the checklist as the authoritative work queue, keep + every open gap visible, and never claim completion while a mandatory item is + unchecked or lacks exact evidence. +- Public GitHub state, commits, pushes, and checks are inspected with `gh` + where supported; record full SHAs and revalidate the exact final SHA after + every semantic or documentation checkpoint. Preserve unrelated worktree + edits, especially the small grammar-only change in + `docs/BNG3_INTEGRATION_PLAN.md`. +- CI pull-request concurrency must key runs by the exact PR head and must not + cancel an in-flight head when a later checklist/documentation push lands; + preserve this contract with a local workflow test. +- Before each checkpoint, inspect and preserve unrelated edits, fast-forward + pull the selected branch when possible, and record the resulting full SHA; + a documentation-only checkpoint still requires fresh exact-head evidence. +- Do not broaden the exception ledger to hide a new mismatch. The current + checklist, not a historical model-specific exception, defines completion. ## Conventions - Read the tree, not memory. Grep before asserting state. @@ -38,7 +120,16 @@ No master function lands until its validation gate is green. Gates live in `test - Don't count lines. Don't write changelogs unless asked. ## Default vs legacy -Default path imports no legacy parse/sim modules. `BIONETGEN_USE_PERL=1` routes through `compat/legacy_runner.py` (subprocess Perl) — the only sanctioned legacy path; it stays. +The default path must not import legacy parse/sim modules. `BIONETGEN_USE_PERL=1` +routes through `compat/legacy_runner.py` (subprocess Perl), but its release +status and the deletion of the remaining legacy trees are still checklist +decisions, not completed work. ## Where the merge work is tracked -`BNG3_unification_spec.md` (work-orders WO-0..WO-7), `BNG3_overcount_analysis.md` (WO-1a), `cpp/CMakeLists.unify.snippet.cmake` (WO-1b/3b/4 build edits), `cpp/nfsim/NFinput/NFinput_fromAst.*` (WO-2), `cpp/ast/ExpressionEval.hpp` (WO-3). +`docs/BNG3_CONVERGENCE_DONE_CHECKLIST.md` (exit criteria), +`docs/BNG3_INTEGRATION_PLAN.md` (completion charter), +`BNG3_unification_spec.md` (work orders WO-0..WO-7), +`BNG3_overcount_analysis.md` (WO-1a), +`cpp/CMakeLists.unify.snippet.cmake` (WO-1b/3b/4 build edits), +`cpp/nfsim/NFinput/NFinput_fromAst.*` (WO-2), and +`cpp/ast/ExpressionEval.hpp` (WO-3). diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b7c83ce..8e58ef41 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,6 +15,28 @@ option(BUILD_PYTHON_BINDINGS "Build pybind11 Python module" ON) option(BUILD_CLI "Build bng_cpp command-line executable" ON) option(BUILD_NFSIM_CLI "Build NFsim command-line executable" OFF) option(BUILD_TESTS "Build C++ tests" ON) +option(NFSIM_ENABLE_LTO + "Enable release link-time optimization when supported by the toolchain" + ON) + +# The compact NFsim energy path benefits from link-time optimization, but the +# option must remain safe on compilers and platforms without a working IPO +# implementation. Probe the complete C/C++ toolchain before applying Release +# IPO properties to the embedded NFsim library and its consumers. +set(NFSIM_IPO_SUPPORTED FALSE) +if(NFSIM_ENABLE_LTO) + include(CheckIPOSupported) + check_ipo_supported( + RESULT NFSIM_IPO_RESULT + OUTPUT NFSIM_IPO_ERROR + LANGUAGES C CXX) + if(NFSIM_IPO_RESULT) + set(NFSIM_IPO_SUPPORTED TRUE) + message(STATUS "BNG3 release LTO enabled") + else() + message(STATUS "BNG3 release LTO disabled: ${NFSIM_IPO_ERROR}") + endif() +endif() include(FetchContent) @@ -123,6 +145,27 @@ endif() # C++ source add_subdirectory(cpp) +function(_bng3_record_ipo target output) + if(TARGET ${target}) + get_target_property(value ${target} INTERPROCEDURAL_OPTIMIZATION_RELEASE) + if(value STREQUAL "value-NOTFOUND") + set(value FALSE) + endif() + else() + set(value NOT_BUILT) + endif() + set(${output} "${value}" PARENT_SCOPE) +endfunction() + +_bng3_record_ipo(nfsim_core BNG3_NFSIM_CORE_IPO) +_bng3_record_ipo(bng_cpp BNG3_BNG_CPP_IPO) +_bng3_record_ipo(_bionetgen_cpp BNG3_PYTHON_IPO) +_bng3_record_ipo(NFsim BNG3_NFSIM_CLI_IPO) +configure_file( + ${CMAKE_CURRENT_SOURCE_DIR}/tests/cpp/lto_contract.in + ${CMAKE_BINARY_DIR}/bng3_lto_contract.txt + @ONLY) + # Tests if(BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/cpp/CMakeLists.txt) add_subdirectory(tests/cpp) diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 23fdfeb3..1673e053 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -67,6 +67,7 @@ add_library(bng_engine STATIC engine/HybridModelGenerator.cpp io/NetWriter.cpp io/NetReader.cpp + io/SbmlReader.cpp io/XmlWriter.cpp io/BnglWriter.cpp io/SbmlWriter.cpp @@ -90,6 +91,7 @@ add_library(bng_engine STATIC ) target_include_directories(bng_engine PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/nfsim/NFinput/TinyXML ${ANTLR_GEN_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/parser ${antlr4_runtime_SOURCE_DIR}/runtime/Cpp/runtime/src @@ -108,6 +110,7 @@ target_link_libraries(bng_engine PUBLIC sundials_sunmatrixdense_static ) target_compile_definitions(bng_engine PUBLIC BNG_CPP_SOURCE_DIR="${CMAKE_SOURCE_DIR}") +target_compile_definitions(bng_engine PUBLIC TIXML_USE_STL) # ============================================================================= # nfsim_core (Network-free stochastic simulator) @@ -193,6 +196,15 @@ else() endif() endif() +# Apply the guarded Release IPO setting to the embedded NFsim implementation. +# Final targets also need the property so the linker can consume the NFsim +# bitcode when it is linked into the unified CLI, Python extension, or legacy +# standalone executable. +if(NFSIM_IPO_SUPPORTED) + set_property(TARGET nfsim_core + PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) +endif() + # ============================================================================= # bionetgen_core — combined static library for all C++ components # ============================================================================= @@ -213,6 +225,10 @@ if(BUILD_CLI) add_executable(bng_cpp main.cpp) target_link_libraries(bng_cpp PRIVATE bionetgen_core) target_compile_definitions(bng_cpp PRIVATE BNG_CPP_SOURCE_DIR="${CMAKE_SOURCE_DIR}") + if(NFSIM_IPO_SUPPORTED) + set_property(TARGET bng_cpp + PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) + endif() endif() # ============================================================================= @@ -233,6 +249,10 @@ if(BUILD_NFSIM_CLI) ${NFSIM_DIR}/NFtest/agentcell/cell ) target_compile_definitions(NFsim PRIVATE TIXML_USE_STL) + if(NFSIM_IPO_SUPPORTED) + set_property(TARGET NFsim + PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) + endif() endif() # ============================================================================= @@ -273,5 +293,10 @@ if(BUILD_PYTHON_BINDINGS) ) target_compile_definitions(_bionetgen_cpp PRIVATE TIXML_USE_STL) + if(NFSIM_IPO_SUPPORTED) + set_property(TARGET _bionetgen_cpp + PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE) + endif() + install(TARGETS _bionetgen_cpp DESTINATION bionetgen COMPONENT python) endif() diff --git a/cpp/actions/ActionDispatch.cpp b/cpp/actions/ActionDispatch.cpp index 1d431e23..cc33f561 100644 --- a/cpp/actions/ActionDispatch.cpp +++ b/cpp/actions/ActionDispatch.cpp @@ -7,11 +7,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include @@ -25,6 +27,7 @@ #include "io/XmlWriter.hpp" #include "io/BnglWriter.hpp" #include "io/NetReader.hpp" +#include "io/SbmlReader.hpp" #include "io/SbmlWriter.hpp" #include "io/SbmlMultiWriter.hpp" #include "io/MatlabWriter.hpp" @@ -142,6 +145,497 @@ double parseScalarValue(const std::string& text, ast::Model& model) { throw std::runtime_error("Unsupported scalar action value: '" + text + "'"); } +std::vector parseSampleTimes(const std::string& text, ast::Model& model) { + std::string value = trim(stripQuotes(text)); + if (value.size() < 2 || value.front() != '[' || value.back() != ']') { + throw std::runtime_error( + "sample_times must be a comma-separated list enclosed in square brackets"); + } + value = value.substr(1, value.size() - 2); + std::vector times; + std::size_t start = 0; + while (start <= value.size()) { + const auto comma = value.find(',', start); + const auto token = trim(value.substr(start, comma == std::string::npos + ? std::string::npos + : comma - start)); + if (token.empty()) { + throw std::runtime_error("sample_times must not contain empty entries"); + } + const double time = parseScalarValue(token, model); + if (!std::isfinite(time)) { + throw std::runtime_error("sample_times must contain only finite values"); + } + times.push_back(time); + if (comma == std::string::npos) break; + start = comma + 1; + } + if (times.empty()) { + throw std::runtime_error("sample_times must not be empty"); + } + for (std::size_t i = 1; i < times.size(); ++i) { + if (times[i] <= times[i - 1]) { + throw std::runtime_error( + "sample_times must be strictly increasing"); + } + } + return times; +} + +struct LegacyNfParamOptions { + bool verbose = false; + bool complex = false; + bool blockSameComplexBinding = false; + bool noComplexScopedLocalFunctions = false; + bool noOnTheFlyObservables = false; + bool binaryOutput = false; + bool printFunctions = false; + bool connectivity = false; + std::optional globalMoleculeLimit; + std::optional traversalLimit; + std::optional seed; + std::optional equilibration; +}; + +std::vector tokenizeLegacyNfParams(const std::string& raw) { + std::vector tokens; + std::string token; + char quote = '\0'; + bool escaped = false; + + for (const char current : raw) { + if (escaped) { + token.push_back(current); + escaped = false; + continue; + } + if (current == '\\' && quote != '\'') { + escaped = true; + continue; + } + if (quote != '\0') { + if (current == quote) { + quote = '\0'; + } else { + token.push_back(current); + } + continue; + } + if (current == '\'' || current == '"') { + quote = current; + } else if (std::isspace(static_cast(current))) { + if (!token.empty()) { + tokens.push_back(std::move(token)); + token.clear(); + } + } else { + token.push_back(current); + } + } + + if (escaped || quote != '\0') { + throw std::runtime_error("NFsim param contains an unterminated quote or escape"); + } + if (!token.empty()) tokens.push_back(std::move(token)); + return tokens; +} + +int parseLegacyNfInt(const std::string& value, const std::string& flag) { + std::size_t consumed = 0; + try { + const long long parsed = std::stoll(value, &consumed); + if (consumed != value.size() || parsed < std::numeric_limits::min() || + parsed > std::numeric_limits::max()) { + throw std::runtime_error("range"); + } + return static_cast(parsed); + } catch (const std::exception&) { + throw std::runtime_error( + "NFsim param flag '" + flag + "' requires an integer value"); + } +} + +unsigned long parseLegacyNfSeed(const std::string& value) { + std::size_t consumed = 0; + try { + const unsigned long parsed = std::stoul(value, &consumed); + if (consumed != value.size()) throw std::runtime_error("trailing"); + return parsed; + } catch (const std::exception&) { + throw std::runtime_error("NFsim param flag '-seed' requires a non-negative integer"); + } +} + +LegacyNfParamOptions parseLegacyNfParams(const std::string& raw, ast::Model& model) { + LegacyNfParamOptions options; + const auto tokens = tokenizeLegacyNfParams(raw); + for (std::size_t index = 0; index < tokens.size(); ++index) { + const std::string& token = tokens[index]; + std::string flag = token; + std::string inlineValue; + if (const auto equals = token.find('='); equals != std::string::npos) { + flag = token.substr(0, equals); + inlineValue = token.substr(equals + 1); + } + flag = lowercase(flag); + + const auto readValue = [&](const std::string& name) -> std::string { + if (!inlineValue.empty()) return inlineValue; + if (index + 1 >= tokens.size() || + tokens[index + 1].rfind("-", 0) == 0) { + throw std::runtime_error( + "NFsim param flag '" + name + "' requires a value"); + } + return tokens[++index]; + }; + + if (flag == "-v" || flag == "--verbose") { + options.verbose = true; + } else if (flag == "-cb" || flag == "--complex" || flag == "-complex") { + options.complex = true; + } else if (flag == "-bscb" || flag == "--bscb") { + options.blockSameComplexBinding = true; + options.complex = true; + } else if (flag == "-nocslf" || flag == "--nocslf") { + options.noComplexScopedLocalFunctions = true; + } else if (flag == "-notf" || flag == "--notf") { + options.noOnTheFlyObservables = true; + } else if (flag == "-b" || flag == "--binary" || flag == "-binary_output") { + options.binaryOutput = true; + } else if (flag == "-ogf" || flag == "--print-functions" || + flag == "-print_functions") { + options.printFunctions = true; + } else if (flag == "-connect" || flag == "-pcg" || flag == "--connect") { + options.connectivity = true; + } else if (flag == "-gml" || flag == "--gml") { + const auto value = lowercase(readValue(flag)); + if (value == "auto" || value == "none" || value == "nolimit") { + options.globalMoleculeLimit = -1; + } else { + const int parsed = parseLegacyNfInt(value, flag); + if (parsed < 0) { + throw std::runtime_error( + "NFsim param flag '-gml' requires a non-negative integer or auto"); + } + options.globalMoleculeLimit = parsed; + } + } else if (flag == "-utl" || flag == "--utl") { + options.traversalLimit = parseLegacyNfInt(readValue(flag), flag); + } else if (flag == "-seed" || flag == "--seed") { + options.seed = parseLegacyNfSeed(readValue(flag)); + } else if (flag == "-eq" || flag == "--equil") { + const auto value = readValue(flag); + const double parsed = parseScalarValue(value, model); + if (!std::isfinite(parsed) || parsed < 0.0) { + throw std::runtime_error( + "NFsim param flag '-eq' requires a finite non-negative value"); + } + options.equilibration = parsed; + } else { + throw std::runtime_error( + "NFsim param flag '" + token + + "' is not supported by the in-process adapter"); + } + } + return options; +} + +std::vector parseScalarList(const std::string& text, + ast::Model& model, + const std::string& name) { + std::string value = trim(stripQuotes(text)); + if (value.size() < 2 || value.front() != '[' || value.back() != ']') { + throw std::runtime_error( + name + " must be a comma-separated list enclosed in square brackets"); + } + + value = value.substr(1, value.size() - 2); + std::vector values; + std::size_t tokenStart = 0; + int parentheses = 0; + int brackets = 0; + char quote = '\0'; + + const auto appendToken = [&](std::size_t tokenEnd) { + const auto token = trim(value.substr(tokenStart, tokenEnd - tokenStart)); + if (token.empty()) { + throw std::runtime_error(name + " must not contain empty entries"); + } + const double parsed = parseScalarValue(token, model); + if (!std::isfinite(parsed)) { + throw std::runtime_error(name + " must contain only finite values"); + } + values.push_back(parsed); + }; + + for (std::size_t i = 0; i < value.size(); ++i) { + const char current = value[i]; + if (quote != '\0') { + if (current == quote && (i == 0 || value[i - 1] != '\\')) { + quote = '\0'; + } + continue; + } + if (current == '\'' || current == '"') { + quote = current; + } else if (current == '(') { + ++parentheses; + } else if (current == ')') { + if (--parentheses < 0) { + throw std::runtime_error(name + " contains unbalanced parentheses"); + } + } else if (current == '[') { + ++brackets; + } else if (current == ']') { + if (--brackets < 0) { + throw std::runtime_error(name + " contains unbalanced brackets"); + } + } else if (current == ',' && parentheses == 0 && brackets == 0) { + appendToken(i); + tokenStart = i + 1; + } + } + + if (quote != '\0' || parentheses != 0 || brackets != 0) { + throw std::runtime_error(name + " contains an unbalanced expression"); + } + if (tokenStart < value.size() || !value.empty()) { + appendToken(value.size()); + } + if (values.empty()) { + throw std::runtime_error(name + " must not be empty"); + } + return values; +} + +std::size_t parseNonNegativeCount(const std::string& text, + ast::Model& model, + const std::string& name) { + const double value = parseScalarValue(text, model); + if (!std::isfinite(value) || value < 0.0 || std::floor(value) != value || + value > static_cast(std::numeric_limits::max())) { + throw std::runtime_error(name + " must be a non-negative integer"); + } + return static_cast(value); +} + +bool parseBoolean(const std::string& text, bool defaultValue = false) { + const auto value = lowercase(stripQuotes(trim(text))); + if (value.empty()) { + return defaultValue; + } + if (value == "1" || value == "true" || value == "yes" || value == "on") { + return true; + } + if (value == "0" || value == "false" || value == "no" || value == "off") { + return false; + } + throw std::runtime_error("Expected a boolean value, got '" + text + "'"); +} + +struct ScanData { + std::vector columns; + std::vector values; +}; + +struct TrajectoryData { + std::vector columns; + std::vector> rows; +}; + +TrajectoryData readDat(const std::filesystem::path& path) { + std::ifstream input(path); + if (!input) { + throw std::runtime_error("sensitivity could not open data file: " + path.string()); + } + + TrajectoryData data; + std::string line; + while (std::getline(input, line)) { + line = trim(line); + if (line.empty()) { + continue; + } + if (line.front() == '#') { + std::istringstream header(line.substr(1)); + header >> std::ws; + std::string column; + while (header >> column) { + data.columns.push_back(column); + } + continue; + } + + std::istringstream rowStream(line); + std::vector row; + double value = 0.0; + while (rowStream >> value) { + if (!std::isfinite(value)) { + throw std::runtime_error( + "sensitivity found a non-finite value in " + path.string()); + } + row.push_back(value); + } + if (!row.empty()) { + data.rows.push_back(std::move(row)); + } + } + + if (data.columns.size() < 2 || data.rows.empty()) { + throw std::runtime_error("sensitivity found an invalid data file: " + path.string()); + } + for (const auto& row : data.rows) { + if (row.size() != data.columns.size()) { + throw std::runtime_error( + "sensitivity found an inconsistent row in " + path.string()); + } + } + return data; +} + +void writeSensitivityFile(const std::filesystem::path& baselinePath, + const std::filesystem::path& perturbedPath, + const std::filesystem::path& outputPath, + double parameterDelta) { + const auto baseline = readDat(baselinePath); + const auto perturbed = readDat(perturbedPath); + if (baseline.columns != perturbed.columns || + baseline.rows.size() != perturbed.rows.size()) { + throw std::runtime_error( + "sensitivity baseline and perturbed trajectories have different shapes"); + } + for (std::size_t row = 0; row < baseline.rows.size(); ++row) { + if (std::abs(baseline.rows[row][0] - perturbed.rows[row][0]) > + 1e-12 * std::max({1.0, std::abs(baseline.rows[row][0]), + std::abs(perturbed.rows[row][0])})) { + throw std::runtime_error( + "sensitivity baseline and perturbed trajectories use different time grids"); + } + } + + std::ofstream output(outputPath); + if (!output) { + throw std::runtime_error("sensitivity could not open output file: " + outputPath.string()); + } + output << std::setw(16) << "# time"; + for (const auto& row : baseline.rows) { + output << " " << std::setw(16) << std::setprecision(8) << std::scientific + << row[0]; + } + output << '\n'; + + for (std::size_t column = 1; column < baseline.columns.size(); ++column) { + output << std::setw(16) << baseline.columns[column]; + for (std::size_t row = 0; row < baseline.rows.size(); ++row) { + const double sensitivity = parameterDelta == 0.0 + ? 0.0 + : (perturbed.rows[row][column] - baseline.rows[row][column]) / + parameterDelta; + output << " " << std::setw(16) << std::setprecision(8) + << std::scientific << sensitivity; + } + output << '\n'; + } +} + +ScanData readLastGdat(const std::filesystem::path& path, const ast::Model& model) { + std::ifstream input(path); + if (!input) { + throw std::runtime_error("parameter_scan could not open observable file: " + path.string()); + } + + std::vector columns; + std::vector lastValues; + std::string line; + while (std::getline(input, line)) { + line = trim(line); + if (line.empty()) { + continue; + } + if (line.front() == '#') { + std::istringstream header(line.substr(1)); + header >> std::ws; + std::string column; + while (header >> column) { + columns.push_back(column); + } + continue; + } + + std::istringstream data(line); + std::vector values; + double value = 0.0; + while (data >> value) { + if (!std::isfinite(value)) { + throw std::runtime_error( + "parameter_scan found a non-finite value in " + path.string()); + } + values.push_back(value); + } + if (!values.empty()) { + lastValues = std::move(values); + } + } + + // Native NFsim output is intentionally headerless. Recover the stable + // column contract from the model rather than inventing names or dropping + // the data from an NF parameter scan. + if (columns.empty()) { + columns.push_back("time"); + for (const auto& observable : model.getObservables()) { + columns.push_back(observable.getName()); + } + } + if (columns.size() < 2 || lastValues.size() != columns.size()) { + throw std::runtime_error( + "parameter_scan found an invalid observable file: " + path.string()); + } + columns.erase(columns.begin()); // drop the time column + lastValues.erase(lastValues.begin()); + return {std::move(columns), std::move(lastValues)}; +} + +void writeScanFile(const std::filesystem::path& path, + const std::string& parameterName, + const std::vector& parameterValues, + const std::vector& data) { + if (parameterValues.size() != data.size() || data.empty()) { + throw std::runtime_error("parameter_scan has no complete scan data"); + } + for (std::size_t i = 1; i < data.size(); ++i) { + if (data[i].columns != data[0].columns || + data[i].values.size() != data[0].values.size()) { + throw std::runtime_error( + "parameter_scan observable columns changed between scan points"); + } + } + + std::ofstream output(path); + if (!output) { + throw std::runtime_error("parameter_scan could not open output file: " + path.string()); + } + output << "#" << std::setw(15) << parameterName; + for (const auto& column : data[0].columns) { + output << " " << std::setw(16) << column; + } + output << '\n'; + for (std::size_t i = 0; i < parameterValues.size(); ++i) { + output << std::setw(16) << std::setprecision(8) << std::scientific + << parameterValues[i]; + for (const auto value : data[i].values) { + output << " " << std::setw(16) << std::setprecision(8) << std::scientific + << value; + } + output << '\n'; + } +} + +std::string formatScalar(double value) { + std::ostringstream output; + output << std::setprecision(17) << value; + return output.str(); +} + std::string readArgument(const ast::Action& action, const std::string& key, const std::string& fallback = {}) { const auto found = action.arguments.find(key); if (found == action.arguments.end()) { @@ -150,6 +644,72 @@ std::string readArgument(const ast::Action& action, const std::string& key, cons return found->second; } +engine::GeneratedNetwork networkFromParsedData( + const io::NetReader::ParseResult& parseResult, ast::Model& model) { + engine::GeneratedNetwork loadedNetwork; + for (const auto& [pattern, concStr] : parseResult.species) { + bool isConstant = false; + std::string cleanPattern = pattern; + if (!cleanPattern.empty() && cleanPattern[0] == '$') { + isConstant = true; + cleanPattern = cleanPattern.substr(1); + } + BNGcore::PatternGraph pg; + pg.set_raw_string(cleanPattern); + ast::SpeciesGraph sg(std::move(pg)); + double concentration = 0.0; + try { + concentration = std::stod(concStr); + } catch (...) { + try { + concentration = model.getParameters().evaluate(concStr); + } catch (...) { + concentration = 0.0; + } + } + ast::Species sp(std::move(sg), concentration, isConstant); + loadedNetwork.species.add(std::move(sp)); + } + + for (const auto& rxnLine : parseResult.reactions) { + std::istringstream iss(rxnLine); + std::string idxStr, reactantsStr, productsStr, rateStr; + iss >> idxStr >> reactantsStr >> productsStr; + std::getline(iss, rateStr); + auto rateStart = rateStr.find_first_not_of(" \t"); + if (rateStart != std::string::npos) { + rateStr = rateStr.substr(rateStart); + } + const auto commentPos = rateStr.find('#'); + if (commentPos != std::string::npos) { + rateStr = rateStr.substr(0, commentPos); + } + while (!rateStr.empty() && std::isspace(rateStr.back())) { + rateStr.pop_back(); + } + + std::vector reactants; + if (reactantsStr != "0") { + std::istringstream rss(reactantsStr); + std::string token; + while (std::getline(rss, token, ',')) { + reactants.push_back(std::stoul(token) - 1); + } + } + std::vector products; + if (productsStr != "0") { + std::istringstream pss(productsStr); + std::string token; + while (std::getline(pss, token, ',')) { + products.push_back(std::stoul(token) - 1); + } + } + loadedNetwork.reactions.add( + ast::Rxn(idxStr, reactants, products, rateStr)); + } + return loadedNetwork; +} + std::string resolveSimulationMethod(const ast::Action& action) { std::string name = lowercase(action.name); if (name == "simulate_ode") { @@ -169,7 +729,11 @@ std::string resolveSimulationMethod(const ast::Action& action) { if (method == "nf") { return "nf"; } - return method; + if (method == "cvode" || method == "ssa" || method == "euler" || + method == "rk4" || method == "psa") { + return method; + } + throw std::runtime_error("simulate: unsupported simulation method: " + method); } std::optional findSpeciesIndex(const engine::GeneratedNetwork& network, const std::string& target) { @@ -348,14 +912,28 @@ void runSimulation( const auto method = resolveSimulationMethod(action); const auto tEnd = stripQuotes(readArgument(action, "t_end", "")); const auto nSteps = stripQuotes(readArgument(action, "n_steps", stripQuotes(readArgument(action, "n_output_steps", "")))); - if (tEnd.empty() || nSteps.empty()) { - throw std::runtime_error("simulate requires t_end and n_steps (or n_output_steps)"); + const auto sampleTimesText = readArgument(action, "sample_times", ""); + const bool hasSampleTimes = !trim(stripQuotes(sampleTimesText)).empty(); + const bool hasStepCount = !nSteps.empty(); + if (tEnd.empty() && (!hasSampleTimes || hasStepCount)) { + throw std::runtime_error( + "simulate requires t_end, or sample_times when n_steps is omitted"); + } + if (hasStepCount && tEnd.empty()) { + throw std::runtime_error("simulate requires t_end when n_steps is provided"); } // Parse simulation options engine::OdeOptions opts; - opts.tEnd = parseScalarValue(tEnd, model); - opts.nSteps = static_cast(parseScalarValue(nSteps, model)); + if (!tEnd.empty()) { + opts.tEnd = parseScalarValue(tEnd, model); + } + if (!nSteps.empty()) { + opts.nSteps = parseNonNegativeCount(nSteps, model, "n_steps"); + if (opts.nSteps == 0) { + throw std::runtime_error("n_steps must be positive"); + } + } // Parse t_start (BNG2 parity) const auto tStartText = readArgument(action, "t_start", ""); @@ -363,6 +941,28 @@ void runSimulation( opts.tStart = parseScalarValue(tStartText, model); } + if (hasSampleTimes && hasStepCount) { + std::cerr << "WARNING: n_steps and sample_times both defined. " + "n_steps takes precedence.\n"; + } else if (hasSampleTimes) { + opts.sampleTimes = parseSampleTimes(sampleTimesText, model); + if (tEnd.empty()) { + opts.tEnd = opts.sampleTimes.back(); + } else if (opts.sampleTimes.back() > opts.tEnd) { + throw std::runtime_error( + "sample_times cannot extend beyond t_end"); + } else if (opts.sampleTimes.back() < opts.tEnd) { + opts.sampleTimes.push_back(opts.tEnd); + } + if (opts.sampleTimes.front() < opts.tStart) { + throw std::runtime_error( + "sample_times cannot occur before t_start"); + } + opts.nSteps = opts.sampleTimes.size() > 1 + ? opts.sampleTimes.size() - 1 + : 1; + } + // Parse method (match BNG2 defaults) if (method == "cvode" || method == "ode") { opts.method = "cvode"; @@ -396,12 +996,28 @@ void runSimulation( opts.rtol = parseScalarValue(rtolText, model); } + const auto maxStepText = readArgument(action, "max_step", ""); + if (!maxStepText.empty()) { + opts.maxStep = parseScalarValue(maxStepText, model); + if (!std::isfinite(opts.maxStep) || opts.maxStep < 0.0) { + throw std::runtime_error("max_step must be finite and non-negative"); + } + } + // Parse steady_state option (BNG2 parity) const auto steadyStateText = lowercase(stripQuotes(readArgument(action, "steady_state", "0"))); opts.steadyState = (steadyStateText == "1" || steadyStateText == "true"); if (opts.steadyState) { opts.steadyStateTol = opts.atol; // Use atol for steady-state check } + const auto steadyStateTolText = readArgument(action, "steady_state_tol", ""); + if (!steadyStateTolText.empty()) { + opts.steadyStateTol = parseScalarValue(steadyStateTolText, model); + if (!std::isfinite(opts.steadyStateTol) || opts.steadyStateTol <= 0.0) { + throw std::runtime_error( + "steady_state_tol must be finite and positive"); + } + } // Parse stop_if expression (BNG2 parity) opts.stopIf = stripQuotes(readArgument(action, "stop_if", "")); @@ -445,7 +1061,14 @@ void runSimulation( // Parse output_step_interval (BNG2 parity: output every N internal steps, mainly for SSA/PLA) const auto outputStepIntervalText = readArgument(action, "output_step_interval", ""); if (!outputStepIntervalText.empty()) { - opts.outputStepInterval = static_cast(parseScalarValue(outputStepIntervalText, model)); + opts.outputStepInterval = parseNonNegativeCount( + outputStepIntervalText, model, "output_step_interval"); + } + + const auto maxSimStepsText = readArgument(action, "max_sim_steps", ""); + if (!maxSimStepsText.empty()) { + opts.maxSimSteps = parseNonNegativeCount( + maxSimStepsText, model, "max_sim_steps"); } // Parse sparse option (request sparse Jacobian for large networks) @@ -460,6 +1083,36 @@ void runSimulation( const auto checkProdScaleText = readArgument(action, "check_product_scale", ""); if (!checkProdScaleText.empty()) { opts.checkProductScale = parseScalarValue(checkProdScaleText, model); + if (!std::isfinite(opts.checkProductScale) || opts.checkProductScale < 0.0) { + throw std::runtime_error( + "check_product_scale must be finite and non-negative"); + } + } + + const bool isOdeMethod = opts.method == "cvode" || opts.method == "euler" || + opts.method == "rk4"; + if (!isOdeMethod && (opts.maxStep > 0.0 || opts.steadyState || opts.sparse)) { + throw std::runtime_error( + "max_step, steady_state, and sparse require an ODE simulation method"); + } + if (!isOdeMethod && opts.method != "ssa" && !opts.stopIf.empty()) { + throw std::runtime_error("stop_if requires an ODE or SSA simulation method"); + } + if (!isOdeMethod && opts.method != "ssa" && hasSampleTimes) { + throw std::runtime_error( + "sample_times requires an ODE or SSA simulation method"); + } + if (opts.method != "ssa" && opts.outputStepInterval > 0) { + throw std::runtime_error( + "output_step_interval requires an SSA simulation method"); + } + if (opts.method != "ssa" && opts.method != "psa" && opts.maxSimSteps > 0) { + throw std::runtime_error( + "max_sim_steps requires an SSA or PSA simulation method"); + } + if (!isOdeMethod && opts.method != "psa" && opts.checkProductScale > 0.0) { + throw std::runtime_error( + "check_product_scale requires an ODE or PSA simulation method"); } if (verbose) { @@ -508,6 +1161,14 @@ void runSimulation( auto plaConfig = engine::PlaConfig::parse(plaConfigStr); engine::PlaSimulator simulator(model, network); result = simulator.simulate(opts, plaConfig); + } else if (opts.method == "psa") { + const auto poplevelText = readArgument(action, "poplevel", "0"); + const double poplevel = parseScalarValue(poplevelText, model); + if (!std::isfinite(poplevel) || poplevel < 0.0) { + throw std::runtime_error("poplevel must be finite and non-negative"); + } + engine::PsaSimulator simulator(model, network); + result = simulator.simulate(opts, poplevel); } else { // ODE/SSA integration engine::OdeIntegrator integrator(model, network); @@ -605,8 +1266,8 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou } }; - const auto writeCurrentNetwork = [&](const io::NetWriterOptions& options = {}) { - const auto outputPath = sourcePath.parent_path() / (sourcePath.stem().string() + ".net"); + const auto writeNetworkAt = [&](const std::filesystem::path& outputPath, + const io::NetWriterOptions& options = {}) { if (loadedNetData.has_value()) { // Write loaded .net data with updated species concentrations std::ofstream out(outputPath); @@ -666,10 +1327,430 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou return outputPath; }; + const auto writeCurrentNetwork = [&](const io::NetWriterOptions& options = {}) { + return writeNetworkAt( + sourcePath.parent_path() / (sourcePath.stem().string() + ".net"), options); + }; + + const auto runNfSimulation = [&](const ast::Action& action) { + const auto prefix = simulationPrefix(action, sourcePath); + const auto xmlPath = sourcePath.parent_path() / (prefix + ".xml"); + + if (parseBoolean(readArgument(action, "continue", "0"))) { + throw std::runtime_error("NFsim does not support 'continue' option"); + } + + const auto tStartText = stripQuotes(readArgument(action, "t_start", "0")); + const double startTime = parseScalarValue(tStartText, model); + if (!std::isfinite(startTime)) { + throw std::runtime_error("NFsim 't_start' must be finite"); + } + if (!stripQuotes(trim(readArgument(action, "nfsim_exec", ""))).empty()) { + throw std::runtime_error( + "NFsim action does not support 'nfsim_exec' yet"); + } + const auto legacyParamText = stripQuotes(trim(readArgument(action, "param", ""))); + const auto legacyParam = parseLegacyNfParams(legacyParamText, model); + + // Parse simulation parameters using the same defaults as the + // standalone simulate_nf action. + const auto tEnd = stripQuotes(readArgument(action, "t_end", "10")); + const auto nSteps = stripQuotes(readArgument(action, "n_steps", "20")); + const auto sampleTimesText = readArgument(action, "sample_times", ""); + std::vector sampleTimes; + if (!trim(stripQuotes(sampleTimesText)).empty()) { + sampleTimes = parseSampleTimes(sampleTimesText, model); + } + const double endTime = parseScalarValue(tEnd, model); + if (!std::isfinite(endTime) || endTime < startTime) { + throw std::runtime_error( + "NFsim 't_end' must be finite and greater than or equal to t_start"); + } + if (!sampleTimes.empty()) { + if (sampleTimes.front() < startTime || sampleTimes.back() > endTime) { + throw std::runtime_error( + "NFsim sample_times must lie between t_start and t_end"); + } + if (sampleTimes.back() < endTime) { + sampleTimes.push_back(endTime); + } + } + const auto gdatPath = sourcePath.parent_path() / (prefix + ".gdat"); + const auto seedText = stripQuotes(readArgument(action, "seed", "")); + const auto utlText = stripQuotes(readArgument(action, "utl", "")); + const auto verboseFlag = lowercase(stripQuotes(readArgument(action, "verbose", "0"))); + const auto complexFlag = lowercase(stripQuotes(readArgument(action, "complex", "1"))); + const auto equilText = stripQuotes(readArgument(action, "equil", "")); + const bool getFinalState = parseBoolean( + readArgument(action, "get_final_state", "1"), true); + const bool nfVerbose = legacyParam.verbose || verboseFlag == "1" || verboseFlag == "true"; + const bool useComplex = legacyParam.complex || complexFlag == "1" || complexFlag == "true"; + const bool evalCSLF = !parseBoolean(readArgument(action, "nocslf", "0")) && + !legacyParam.noComplexScopedLocalFunctions; + const bool connectivityFlag = legacyParam.connectivity || + parseBoolean(readArgument(action, "pcg", "0")); + const bool disableOnTheFly = legacyParam.noOnTheFlyObservables || + parseBoolean(readArgument(action, "notf", "0")); + const bool outputFunctions = legacyParam.printFunctions || + parseBoolean(readArgument(action, "print_functions", "0")); + const bool binaryOutput = legacyParam.binaryOutput || + parseBoolean(readArgument(action, "binary_output", "0")); + + double equilibration = 0.0; + if (!equilText.empty()) { + equilibration = parseScalarValue(equilText, model); + if (!std::isfinite(equilibration) || equilibration < 0.0) { + throw std::runtime_error("NFsim 'equil' must be finite and non-negative"); + } + } else if (legacyParam.equilibration.has_value()) { + equilibration = *legacyParam.equilibration; + } + + int globalMoleculeLimit = 200000; + const auto gmlText = stripQuotes(readArgument(action, "gml", "")); + if (!gmlText.empty()) { + const auto normalized = lowercase(gmlText); + if (normalized == "auto" || normalized == "none" || normalized == "nolimit") { + globalMoleculeLimit = -1; + } else { + globalMoleculeLimit = parseLegacyNfInt(gmlText, "gml"); + if (globalMoleculeLimit < 0) { + throw std::runtime_error( + "NFsim 'gml' requires a non-negative integer or auto"); + } + } + } else if (legacyParam.globalMoleculeLimit.has_value()) { + globalMoleculeLimit = *legacyParam.globalMoleculeLimit; + } + const bool explicitTraversalLimit = + !utlText.empty() || legacyParam.traversalLimit.has_value(); + const int requestedTraversalLimit = utlText.empty() + ? legacyParam.traversalLimit.value_or(3) + : parseLegacyNfInt(utlText, "utl"); + // Let the adapter calculate its recommendation for the compatibility + // path, then apply the action's explicit/native default below. The + // direct builder uses this value as an output accumulator. + int suggestedTraversalLimit = -1; + const bool blockSameComplexBinding = legacyParam.blockSameComplexBinding; + const bool complexConstruction = useComplex || blockSameComplexBinding; + const std::string effectiveSeedText = !seedText.empty() + ? seedText + : (legacyParam.seed.has_value() ? std::to_string(*legacyParam.seed) : ""); + + if (verbose) { + std::cerr << "[bng_cpp] Running NFSim in-process from AST model\n"; + } + + // Action commands can edit the generated network before an NFsim + // action. Keep those current seed amounts as an adapter-local + // override so the original AST seed expressions remain unchanged for + // resetConcentrations and later network generation. + NFinput::SeedAmountOverrides seedAmountOverrides; + if (network.has_value()) { + for (std::size_t index = 0; index < network->species.size(); ++index) { + const auto& species = network->species.get(index); + const auto pattern = species.getSpeciesGraph().toString(); + seedAmountOverrides[pattern] = species.getAmount(); + if (!species.getCompartment().empty()) { + const auto& compartment = species.getCompartment(); + seedAmountOverrides["@" + compartment + "::" + pattern] = + species.getAmount(); + seedAmountOverrides["@" + compartment + ":" + pattern] = + species.getAmount(); + } + } + } + + // Direct construction is the default. XML remains an explicit + // compatibility bridge while the direct adapter is being qualified. + NFcore::System *nfSystem = NFinput::buildSystemFromAstWithSeedOverrides( + model, + useComplex, + blockSameComplexBinding, + globalMoleculeLimit, + nfVerbose, + suggestedTraversalLimit, + sourcePath, + seedAmountOverrides); + + if (!nfSystem) { + if (std::getenv("BNG_NFSIM_REQUIRE_DIRECT") != nullptr) { + throw std::runtime_error( + "NFsim direct AST initialization required but unavailable"); + } + if (std::getenv("BNG_NFSIM_ALLOW_XML_FALLBACK") == nullptr) { + throw std::runtime_error( + "NFsim direct AST initialization unavailable; XML fallback disabled " + "(set BNG_NFSIM_ALLOW_XML_FALLBACK=1 to opt in)"); + } + if (verbose) { + std::cerr << "[bng_cpp] AST adapter returned nullptr; using in-memory XML fallback...\n"; + } + nfSystem = NFinput::initializeFromModel( + &model, + complexConstruction, + globalMoleculeLimit, + nfVerbose, + suggestedTraversalLimit); + } + + if (!nfSystem) { + if (verbose) { + std::cerr << "[bng_cpp] In-memory XML fallback failed; writing XML fallback...\n"; + } + const auto xmlContent = io::XmlWriter::write( + model, network.has_value() ? &(*network) : nullptr); + std::ofstream xmlOut(xmlPath); + if (!xmlOut) { + throw std::runtime_error("Failed to write XML for NFSim: " + xmlPath.string()); + } + xmlOut << xmlContent; + if (!xmlOut) { + throw std::runtime_error("Failed to write XML for NFSim: " + xmlPath.string()); + } + nfSystem = NFinput::initializeFromXML( + xmlPath.string(), complexConstruction, globalMoleculeLimit, nfVerbose, + suggestedTraversalLimit, evalCSLF, connectivityFlag); + } + + if (!nfSystem) { + throw std::runtime_error("NFSim: Failed to initialize system: " + prefix); + } + + // The legacy initializer applies these switches while reading XML. + // Apply the same runtime policy to a directly-built System. + nfSystem->setEvaluateComplexScopedLocalFunctions(evalCSLF); + nfSystem->useConnectivityFlag(connectivityFlag); + const int effectiveTraversalLimit = explicitTraversalLimit + ? requestedTraversalLimit + : 3; + nfSystem->setUniversalTraversalLimit(effectiveTraversalLimit); + if (nfVerbose) { + std::cerr << "[bng_cpp] Universal traversal limit = " + << effectiveTraversalLimit << "\n"; + } + if (disableOnTheFly) { + nfSystem->turnOff_OnTheFlyObs(); + } + if (outputFunctions) { + nfSystem->turnOnGlobalFuncOut(); + } + if (binaryOutput) { + nfSystem->setOutputToBinary(); + } + + if (!effectiveSeedText.empty()) { + nfSystem->seedRNG(std::stoul(effectiveSeedText)); + } + + // Match NFsim's absolute-start contract before preparation so generic + // time() functions and initial propensities see the requested clock. + nfSystem->setCurrentTime(startTime); + nfSystem->registerOutputFileLocation(gdatPath.string()); + nfSystem->prepareForSimulation(); + if (!nfSystem->isOutputtingBinary()) { + nfSystem->outputAllObservableNames(); + } + if (equilibration > 0.0) { + nfSystem->equilibrate(equilibration); + } + if (sampleTimes.empty()) { + nfSystem->sim(endTime - startTime, std::stol(nSteps), nfVerbose); + } else { + for (const double sampleTime : sampleTimes) { + nfSystem->stepTo(sampleTime); + nfSystem->outputAllObservableCounts(sampleTime); + nfSystem->tryToDump(); + } + } + + if (getFinalState) { + const auto speciesPath = sourcePath.parent_path() / (prefix + ".species"); + nfSystem->saveSpecies(speciesPath.string()); + } + + if (verbose) { + std::cerr << "[bng_cpp] NFSim completed. Output: " << gdatPath << "\n"; + } + delete nfSystem; + }; + + // Execute protocol actions through the same stateful dispatcher used by + // the standalone simulate_protocol action. Parameter scans need a + // per-point prefix, so keeping this as one helper prevents protocol + // scans from silently taking a different simulation path. + const auto runProtocol = [&](const std::optional& prefixOverride) { + const auto& protocol = model.getSimulationProtocol(); + if (protocol.empty()) { + throw std::runtime_error("simulate_protocol requires a non-empty protocol block"); + } + + for (const auto& protoAction : protocol) { + const auto protoName = lowercase(protoAction.name); + if (protoName == "simulate_nf" || + (protoName == "simulate" && + resolveSimulationMethod(protoAction) == "nf")) { + ast::Action actualAction = protoAction; + if (prefixOverride.has_value()) { + actualAction.arguments["prefix"] = prefixOverride->string(); + actualAction.arguments.erase("suffix"); + } + runNfSimulation(actualAction); + continue; + } + + if (protoName == "simulate" || protoName == "simulate_ode" || + protoName == "simulate_ssa" || protoName == "simulate_pla" || + protoName == "simulate_psa") { + ensureNetwork(); + ast::Action actualAction = protoAction; + if (protoName == "simulate_pla" || protoName == "simulate_psa") { + actualAction.arguments["method"] = + protoName == "simulate_pla" ? "pla" : "psa"; + } + if (prefixOverride.has_value()) { + actualAction.arguments["prefix"] = prefixOverride->string(); + actualAction.arguments.erase("suffix"); + } + if (!lastSimulationState.empty()) { + actualAction.arguments["continue"] = "1"; + } + runSimulation(model, actualAction, sourcePath, *network, verbose, + lastSimulationState, lastSimulationEndTime); + continue; + } + + if (protoName == "setparameter") { + const auto target = stripQuotes(readArgument(protoAction, "target", "")); + const auto valueText = readArgument(protoAction, "value", ""); + if (target.empty() || trim(valueText).empty()) { + throw std::runtime_error( + "setParameter in a protocol requires target and value"); + } + if (!model.getParameters().contains(target)) { + throw std::runtime_error("setParameter: unknown parameter: " + target); + } + const double value = parseScalarValue(valueText, model); + model.getParameters().add( + ast::Parameter(target, ast::Expression::number(value))); + model.getParameters().evaluateAll(); + if (network.has_value()) { + const auto savedAmounts = snapshotConcentrations(*network); + network = generator.generate(sourcePath); + restoreConcentrations(*network, savedAmounts); + } + continue; + } + + if (protoName == "setconcentration" || protoName == "addconcentration" || + protoName == "add_concentration") { + ensureNetwork(); + const auto target = readArgument(protoAction, "target", ""); + const auto valueText = readArgument(protoAction, "value", ""); + if (trim(target).empty() || trim(valueText).empty()) { + throw std::runtime_error( + protoName == "setconcentration" + ? "setConcentration in a protocol requires target and value" + : "addConcentration in a protocol requires target and value"); + } + const auto found = findSpeciesIndex(*network, target); + if (!found.has_value()) { + throw std::runtime_error( + "Protocol concentration target species not found: " + + stripQuotes(target)); + } + const double value = parseScalarValue(valueText, model); + if (protoName == "setconcentration") { + network->species.get(*found).setAmount(value); + } else { + network->species.get(*found).setAmount( + network->species.get(*found).getAmount() + value); + } + continue; + } + + if (protoName == "saveparameters" || protoName == "save_parameters") { + const auto label = stripQuotes(readArgument(protoAction, "value", "default")); + std::unordered_map snapshot; + for (const auto& param : model.getParameters().all()) { + snapshot[param.getName()] = param.getValue(); + } + savedParameters[label] = std::move(snapshot); + continue; + } + + if (protoName == "resetparameters" || protoName == "reset_parameters") { + const auto label = stripQuotes(readArgument(protoAction, "value", "default")); + const auto found = savedParameters.find(label); + if (found == savedParameters.end()) { + throw std::runtime_error("resetParameters label not found: " + label); + } + for (const auto& [name, value] : found->second) { + model.getParameters().add( + ast::Parameter(name, ast::Expression::number(value))); + } + model.getParameters().evaluateAll(); + if (network.has_value()) { + const auto savedAmounts = snapshotConcentrations(*network); + network = generator.generate(sourcePath); + restoreConcentrations(*network, savedAmounts); + } + continue; + } + + if (protoName == "saveconcentrations" || protoName == "save_concentrations") { + ensureNetwork(); + const auto label = stripQuotes(readArgument(protoAction, "value", "default")); + savedConcentrations[label] = snapshotConcentrations(*network); + continue; + } + + if (protoName == "resetconcentrations" || protoName == "reset_concentrations") { + ensureNetwork(); + const auto label = stripQuotes(readArgument(protoAction, "value", "default")); + const auto found = savedConcentrations.find(label); + if (found != savedConcentrations.end()) { + restoreConcentrations(*network, found->second); + } else if (label != "default") { + throw std::runtime_error("resetConcentrations label not found: " + label); + } else { + const auto& seeds = model.getSeedSpecies(); + for (std::size_t i = 0; i < network->species.size(); ++i) { + if (i < seeds.size()) { + try { + network->species.get(i).setAmount( + seeds[i].getAmount().evaluate([&](const std::string& name) { + return model.getParameters().evaluate(name); + })); + } catch (const std::exception& error) { + throw std::runtime_error( + "resetConcentrations could not evaluate seed species " + + std::to_string(i + 1) + ": " + error.what()); + } + } else { + network->species.get(i).setAmount(0.0); + } + } + } + continue; + } + + throw std::runtime_error( + "Unsupported action in protocol: " + protoAction.name); + } + }; + for (const auto& action : model.getActions()) { // Normalize action name to lowercase for case-insensitive matching std::string actionName = lowercase(action.name); + // BNG2 exposes readNetwork and readModel as aliases for readFile. + // Normalize here too so programmatically constructed actions get the + // same semantics as parsed BNGL sources. + if (actionName == "readnetwork" || actionName == "readmodel") { + actionName = "readfile"; + } + if (actionName == "readfile") { const auto filepath = stripQuotes(readArgument(action, "file", "")); if (filepath.empty()) { @@ -682,7 +1763,8 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou path = sourcePath.parent_path() / filepath; } - if (path.extension() == ".net") { + const auto extension = lowercase(path.extension().string()); + if (extension == ".net") { // Parse .net file auto parseResult = io::NetReader::parse(path); if (!parseResult.success) { @@ -774,7 +1856,7 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou std::cerr << "[bng_cpp] Species: " << network->species.size() << "\n"; std::cerr << "[bng_cpp] Reactions: " << network->reactions.size() << "\n"; } - } else if (path.extension() == ".bngl") { + } else if (extension == ".bngl") { auto includedModel = bng::parser::parseModelFromFile(path.string()); model.merge(*includedModel); model.getParameters().evaluateAll(); @@ -787,6 +1869,34 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou std::cerr << "[bng_cpp] Reaction rules: " << includedModel->getReactionRules().size() << "\n"; std::cerr << "[bng_cpp] Functions: " << includedModel->getFunctions().size() << "\n"; } + } else if (extension == ".xml") { + const auto atomizeText = lowercase( + trim(stripQuotes(readArgument(action, "atomize", "0")))); + const bool atomize = atomizeText == "1" || atomizeText == "true" || + atomizeText == "yes" || atomizeText == "on"; + auto parseResult = io::SbmlReader::parse(path, atomize); + if (!parseResult.success) { + throw std::runtime_error( + "Failed to read SBML file: " + parseResult.error); + } + for (const auto& [name, value] : parseResult.parameters) { + model.getParameters().add( + ast::Parameter(name, ast::Expression::number(value))); + } + for (const auto& [name, expression] : parseResult.functions) { + model.addFunction(ast::Function( + name, {}, bng::parser::parseExpression(expression))); + } + model.getParameters().evaluateAll(); + network = networkFromParsedData(parseResult, model); + loadedNetData = std::move(parseResult); + if (verbose) { + std::cerr << "[bng_cpp] Read flat SBML file: " << path << "\n"; + std::cerr << "[bng_cpp] Parameters: " + << loadedNetData->parameters.size() << "\n"; + std::cerr << "[bng_cpp] Species: " << network->species.size() << "\n"; + std::cerr << "[bng_cpp] Reactions: " << network->reactions.size() << "\n"; + } } else { throw std::runtime_error("readFile: unsupported file type: " + path.extension().string()); } @@ -985,7 +2095,6 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou } } - network = generator.generate(sourcePath); const auto evalExprText = readArgument(action, "evaluate_expressions", "1"); bool evalExpr = true; { @@ -994,6 +2103,15 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou } io::NetWriterOptions writerOptions; writerOptions.evaluateExpressions = evalExpr; + if (loadedNetData.has_value() && network.has_value()) { + // readFile(.net/.xml) already supplied a concrete network. Keep + // that network when the following generate_network action is + // explicitly requested with overwrite=1; regenerating from the + // action-only model would silently discard the imported graph. + writeCurrentNetwork(writerOptions); + continue; + } + network = generator.generate(sourcePath); writeCurrentNetwork(writerOptions); // Triggers NetWriter::buildDerivedRateParams continue; } @@ -1313,118 +2431,9 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou continue; } - if (actionName == "simulate_nf") { - const auto prefix = simulationPrefix(action, sourcePath); - const auto xmlPath = sourcePath.parent_path() / (prefix + ".xml"); - - // Parse simulation parameters - const auto tEnd = stripQuotes(readArgument(action, "t_end", "10")); - const auto nSteps = stripQuotes(readArgument(action, "n_steps", "20")); - const auto gdatPath = sourcePath.parent_path() / (prefix + ".gdat"); - const auto seedText = stripQuotes(readArgument(action, "seed", "")); - const auto utlText = stripQuotes(readArgument(action, "utl", "3")); - const auto verboseFlag = lowercase(stripQuotes(readArgument(action, "verbose", "0"))); - const auto complexFlag = lowercase(stripQuotes(readArgument(action, "complex", "1"))); - const auto getFinalState = lowercase(stripQuotes(readArgument(action, "get_final_state", "1"))); - bool nfVerbose = (verboseFlag == "1" || verboseFlag == "true"); - bool useComplex = (complexFlag == "1" || complexFlag == "true"); - bool evalCSLF = lowercase(stripQuotes(readArgument(action, "nocslf", "0"))) != "1"; - bool connectivityFlag = lowercase(stripQuotes(readArgument(action, "pcg", "0"))) == "1"; - - int globalMoleculeLimit = 200000; - const auto gmlText = stripQuotes(readArgument(action, "gml", "")); - if (!gmlText.empty()) globalMoleculeLimit = std::stoi(gmlText); - - int suggestedTraversalLimit = utlText.empty() ? 3 : std::stoi(utlText); - - if (verbose) { - std::cerr << "[bng_cpp] Running NFSim in-process from AST model\n"; - } - - // Try the direct AST adapter first. The compatibility XML paths - // remain available for sections that are still explicitly gated. - NFcore::System *nfSystem = NFinput::buildSystemFromAst( - model, - useComplex, - globalMoleculeLimit, - nfVerbose, - suggestedTraversalLimit, - sourcePath); - - if (!nfSystem) { - if (verbose) { - std::cerr << "[bng_cpp] AST adapter returned nullptr; using in-memory XML fallback...\n"; - } - nfSystem = NFinput::initializeFromModel( - &model, - useComplex, - globalMoleculeLimit, - nfVerbose, - suggestedTraversalLimit); - } - - if (!nfSystem) { - if (verbose) { - std::cerr << "[bng_cpp] In-memory XML fallback failed; writing XML fallback...\n"; - } - const auto xmlContent = io::XmlWriter::write( - model, network.has_value() ? &(*network) : nullptr); - std::ofstream xmlOut(xmlPath); - if (!xmlOut) { - throw std::runtime_error("Failed to write XML for NFSim: " + xmlPath.string()); - } - xmlOut << xmlContent; - if (!xmlOut) { - throw std::runtime_error("Failed to write XML for NFSim: " + xmlPath.string()); - } - nfSystem = NFinput::initializeFromXML( - xmlPath.string(), useComplex, globalMoleculeLimit, nfVerbose, - suggestedTraversalLimit, evalCSLF, connectivityFlag); - } - - if (!nfSystem) { - throw std::runtime_error("NFSim: Failed to initialize system from XML: " + xmlPath.string()); - } - - // The legacy initializer applies these switches while reading XML. - // Apply the same runtime policy to a directly-built System. - nfSystem->setEvaluateComplexScopedLocalFunctions(evalCSLF); - nfSystem->useConnectivityFlag(connectivityFlag); - nfSystem->setUniversalTraversalLimit(suggestedTraversalLimit); - - // Seed the RNG if requested - if (!seedText.empty()) { - unsigned long seed = std::stoul(seedText); - nfSystem->seedRNG(seed); - } - - // Register output file - nfSystem->registerOutputFileLocation(gdatPath.string()); - - // Handle species output - if (getFinalState == "1" || getFinalState == "true") { - // Will save species after simulation - } - - // Prepare and run simulation - nfSystem->prepareForSimulation(); - - double simTime = std::stod(tEnd); - long int sampleTimes = std::stol(nSteps); - - nfSystem->sim(simTime, sampleTimes, nfVerbose); - - // Save final species state if requested - if (getFinalState == "1" || getFinalState == "true") { - const auto speciesPath = sourcePath.parent_path() / (prefix + ".species"); - nfSystem->saveSpecies(speciesPath.string()); - } - - if (verbose) { - std::cerr << "[bng_cpp] NFSim completed. Output: " << gdatPath << "\n"; - } - - delete nfSystem; + if (actionName == "simulate_nf" || + (actionName == "simulate" && resolveSimulationMethod(action) == "nf")) { + runNfSimulation(action); continue; } @@ -1442,36 +2451,303 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou throw std::runtime_error("parameter_scan requires parameter argument"); } - const auto minValue = parseScalarValue(readArgument(action, "par_min", ""), model); - const auto maxValue = parseScalarValue(readArgument(action, "par_max", ""), model); - const auto points = static_cast(std::max(1.0, parseScalarValue(readArgument(action, "n_scan_pts", "1"), model))); - const auto logScale = lowercase(stripQuotes(readArgument(action, "log_scale", "0"))) == "1" - || lowercase(stripQuotes(readArgument(action, "log_scale", "0"))) == "true"; + if (!model.getParameters().contains(parameterName)) { + throw std::runtime_error("parameter_scan: unknown parameter: " + parameterName); + } + + const auto minText = readArgument(action, "par_min", ""); + const auto maxText = readArgument(action, "par_max", ""); + const auto pointsText = readArgument(action, "n_scan_pts", ""); + const bool hasMin = !trim(stripQuotes(minText)).empty(); + const bool hasMax = !trim(stripQuotes(maxText)).empty(); + const bool hasPoints = !trim(stripQuotes(pointsText)).empty(); + const auto explicitValuesText = readArgument( + action, "par_scan_vals", readArgument(action, "values", "")); + const bool hasExplicitValues = + !trim(stripQuotes(explicitValuesText)).empty(); + + // BNG2 gives a complete min/max/n_scan_pts range precedence over an + // explicit list. Partial range arguments do not suppress a list; + // without a list, they remain an error. + std::vector scanValues; + if (hasMin && hasMax && hasPoints) { + const double minValue = parseScalarValue(minText, model); + const double maxValue = parseScalarValue(maxText, model); + const auto points = parseNonNegativeCount( + pointsText, model, "n_scan_pts"); + if (minValue == maxValue) { + if (points < 1) { + throw std::runtime_error( + "n_scan_pts must be at least one when par_min equals par_max"); + } + } else if (points <= 1) { + throw std::runtime_error( + "n_scan_pts must be greater than one when par_min differs from par_max"); + } + const bool logScale = parseBoolean( + readArgument(action, "log_scale", "0")); + if (logScale && (minValue <= 0.0 || maxValue <= 0.0)) { + throw std::runtime_error( + "parameter_scan with log_scale requires positive par_min and par_max"); + } + scanValues.reserve(points); + for (std::size_t i = 0; i < points; ++i) { + const double alpha = points == 1 + ? 0.0 + : static_cast(i) / static_cast(points - 1); + if (logScale) { + scanValues.push_back(std::exp( + std::log(minValue) + + alpha * (std::log(maxValue) - std::log(minValue)))); + } else { + scanValues.push_back( + minValue + alpha * (maxValue - minValue)); + } + } + } else if (hasExplicitValues) { + scanValues = parseScalarList( + explicitValuesText, model, "par_scan_vals"); + } else if (hasMin || hasMax || hasPoints) { + throw std::runtime_error( + "parameter_scan requires par_min, par_max, and n_scan_pts " + "together"); + } else { + throw std::runtime_error( + "parameter_scan requires par_scan_vals or par_min, par_max, and n_scan_pts"); + } + + const auto method = lowercase(stripQuotes( + readArgument(action, "method", "ode"))); + const bool nfScan = method == "nf"; + const auto parallelText = readArgument(action, "parallel", ""); + std::size_t parallelWorkers = 0; + if (!trim(stripQuotes(parallelText)).empty()) { + const auto parallelValue = parseNonNegativeCount( + parallelText, model, "parallel"); + if (parallelValue > 0) { + // BNG2 treats parallel=>1 as an enable flag and takes the + // worker count from num_cores. Accept a worker count in + // parallel itself as well, matching the Python API. + if (parallelValue == 1) { + const auto numCoresText = readArgument( + action, "num_cores", ""); + if (trim(stripQuotes(numCoresText)).empty()) { + const auto detected = std::thread::hardware_concurrency(); + parallelWorkers = detected == 0 + ? 1 + : static_cast(detected); + } else { + parallelWorkers = parseNonNegativeCount( + numCoresText, model, "num_cores"); + if (parallelWorkers == 0) { + throw std::runtime_error( + "num_cores must be positive when parameter_scan parallelism is enabled"); + } + } + } else { + parallelWorkers = parallelValue; + } + parallelWorkers = std::min(parallelWorkers, scanValues.size()); + } + } + const bool resetConcentrations = parseBoolean( + readArgument(action, "reset_conc", "1"), true); - ast::Action simulateAction = action; - simulateAction.name = "simulate"; - simulateAction.arguments["method"] = readArgument(action, "method", "ode"); + ensureNetwork(); + const auto scanInitialConcentrations = snapshotConcentrations(*network); + const auto originalLastState = lastSimulationState; + const double originalLastEndTime = lastSimulationEndTime; + std::vector originalParameters; + for (const auto& parameter : model.getParameters().all()) { + originalParameters.push_back(parameter); + } + const auto savedConcentrationsBeforeScan = savedConcentrations; + const auto savedParametersBeforeScan = savedParameters; - for (std::size_t i = 0; i < points; ++i) { - const double alpha = points == 1 ? 0.0 : static_cast(i) / static_cast(points - 1); - double value = 0.0; - if (logScale) { - if (minValue <= 0.0 || maxValue <= 0.0) { - throw std::runtime_error("parameter_scan with log_scale requires positive par_min and par_max"); - } - value = std::exp(std::log(minValue) + alpha * (std::log(maxValue) - std::log(minValue))); - } else { - value = minValue + alpha * (maxValue - minValue); + std::filesystem::path scanBase = stripQuotes( + readArgument(action, "prefix", sourcePath.stem().string())); + if (!scanBase.is_absolute()) { + scanBase = sourcePath.parent_path() / scanBase; + } + const auto suffix = stripQuotes(readArgument(action, "suffix", "")); + if (!suffix.empty()) { + scanBase += "_" + suffix; + } else { + scanBase += "_" + parameterName; + } + const auto workdir = scanBase; + std::filesystem::create_directories(workdir); + const auto localStem = scanBase.filename().string(); + + const auto restoreScanContext = [&]() { + for (const auto& parameter : originalParameters) { + model.getParameters().add(parameter); } + model.getParameters().evaluateAll(); + network = generator.generate(sourcePath); + restoreConcentrations(*network, scanInitialConcentrations); + lastSimulationState = originalLastState; + lastSimulationEndTime = originalLastEndTime; + savedConcentrations = savedConcentrationsBeforeScan; + savedParameters = savedParametersBeforeScan; + }; - model.getParameters().add(ast::Parameter(parameterName, ast::Expression::number(value))); + const auto runScanPoint = [&](std::size_t index) { + const auto priorConcentrations = snapshotConcentrations(*network); + model.getParameters().add(ast::Parameter( + parameterName, ast::Expression::number(scanValues[index]))); model.getParameters().evaluateAll(); network = generator.generate(sourcePath); - writeCurrentNetwork(); // Still write .net file for compatibility + restoreConcentrations( + *network, + resetConcentrations ? scanInitialConcentrations + : priorConcentrations); + lastSimulationState.clear(); + lastSimulationEndTime = 0.0; + savedConcentrations.clear(); + savedParameters.clear(); + + std::ostringstream localName; + localName << localStem << "_" << std::setfill('0') + << std::setw(5) << (index + 1); + const auto localPrefix = workdir / localName.str(); + if (method == "protocol") { + runProtocol(localPrefix); + } else if (nfScan) { + ast::Action nfAction = action; + nfAction.name = "simulate_nf"; + nfAction.arguments["prefix"] = localPrefix.string(); + nfAction.arguments.erase("suffix"); + runNfSimulation(nfAction); + } else { + ast::Action simulateAction = action; + simulateAction.name = "simulate"; + simulateAction.arguments["prefix"] = localPrefix.string(); + simulateAction.arguments.erase("suffix"); + runSimulation(model, simulateAction, sourcePath, *network, + verbose, lastSimulationState, + lastSimulationEndTime); + } + }; - simulateAction.arguments["prefix"] = simulationPrefix(action, sourcePath, i); - runSimulation(model, simulateAction, sourcePath, *network, verbose, lastSimulationState, lastSimulationEndTime); + std::vector scanData; + try { + if (method == "protocol" && model.getSimulationProtocol().empty()) { + throw std::runtime_error( + "parameter_scan method='protocol' requires a protocol block"); + } + if (parallelWorkers == 0) { + for (std::size_t i = 0; i < scanValues.size(); ++i) { + runScanPoint(i); + std::ostringstream localName; + localName << localStem << "_" << std::setfill('0') + << std::setw(5) << (i + 1); + const auto localPrefix = workdir / localName.str(); + scanData.push_back( + readLastGdat(localPrefix.string() + ".gdat", model)); + } + } else { +#if defined(_WIN32) || defined(__WIN32__) || defined(__CYGWIN__) + // The embedded backend has no safe Windows fork equivalent. + // Preserve action semantics and output ordering while keeping + // the parallel request usable on that platform. + if (verbose) { + std::cerr << "[bng_cpp] parameter_scan: running workers serially " + << "on Windows\n"; + } + for (std::size_t i = 0; i < scanValues.size(); ++i) { + runScanPoint(i); + } +#else + // Fork after the model/network snapshot is ready. Each + // child receives an independent copy-on-write model and + // network, so rates, RNG state, and mutable simulation + // state cannot leak between scan points. Contiguous + // batches retain BNG2's reset_conc=false behavior. + struct ScanChild { + pid_t pid; + std::size_t begin; + std::filesystem::path errorPath; + }; + const auto batchSize = (scanValues.size() + parallelWorkers - 1) / + parallelWorkers; + std::vector children; + std::string launchError; + for (std::size_t begin = 0; begin < scanValues.size(); + begin += batchSize) { + const auto end = std::min(begin + batchSize, scanValues.size()); + const auto errorPath = workdir / + (localStem + "_worker_" + std::to_string(begin) + ".error"); + std::error_code removeError; + std::filesystem::remove(errorPath, removeError); + const auto pid = fork(); + if (pid < 0) { + launchError = "parameter_scan could not fork a worker"; + break; + } + if (pid == 0) { + std::size_t activeIndex = begin; + try { + for (; activeIndex < end; ++activeIndex) { + runScanPoint(activeIndex); + } + _exit(0); + } catch (const std::exception& error) { + std::ofstream errorFile(errorPath); + errorFile << "step " << (activeIndex + 1) << ": " + << error.what(); + _exit(1); + } catch (...) { + std::ofstream errorFile(errorPath); + errorFile << "step " << (activeIndex + 1) + << ": unknown worker failure"; + _exit(1); + } + } + children.push_back({pid, begin, errorPath}); + } + + std::string workerError = launchError; + for (const auto& child : children) { + int status = 0; + if (waitpid(child.pid, &status, 0) < 0) { + if (workerError.empty()) { + workerError = "parameter_scan could not wait for a worker"; + } + continue; + } + if (workerError.empty() && + (!WIFEXITED(status) || WEXITSTATUS(status) != 0)) { + std::ifstream errorFile(child.errorPath); + std::ostringstream errorText; + errorText << errorFile.rdbuf(); + workerError = errorText.str(); + if (workerError.empty()) { + workerError = "worker terminated abnormally"; + } + } + } + if (!workerError.empty()) { + throw std::runtime_error( + "parameter_scan worker failed: " + workerError); + } +#endif + for (std::size_t i = 0; i < scanValues.size(); ++i) { + std::ostringstream localName; + localName << localStem << "_" << std::setfill('0') + << std::setw(5) << (i + 1); + const auto localPrefix = workdir / localName.str(); + scanData.push_back( + readLastGdat(localPrefix.string() + ".gdat", model)); + } + } + writeScanFile(scanBase.string() + ".scan", parameterName, + scanValues, scanData); + } catch (...) { + restoreScanContext(); + throw; } + restoreScanContext(); continue; } @@ -1513,10 +2789,10 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou ast::Action simAction; simAction.name = "simulate"; simAction.arguments["method"] = "ode"; - simAction.arguments["t_end"] = std::to_string(tEnd); + simAction.arguments["t_end"] = formatScalar(tEnd); simAction.arguments["n_steps"] = std::to_string(nSteps); - simAction.arguments["atol"] = std::to_string(atol); - simAction.arguments["rtol"] = std::to_string(rtol); + simAction.arguments["atol"] = formatScalar(atol); + simAction.arguments["rtol"] = formatScalar(rtol); simAction.arguments["prefix"] = simPrefix; if (steadyState) { simAction.arguments["steady_state"] = "1"; @@ -1573,6 +2849,16 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou auto bumpAction = makeSimulateAction(bumpPrefix, parseScalarValue(tEndText, model), false); runSimulation(model, bumpAction, sourcePath, *network, verbose, lastSimulationState, lastSimulationEndTime); + const double parameterDelta = newValue - paramValue; + writeSensitivityFile( + outputDir / (prefix + "_basecase_" + suffix + ".cdat"), + outputDir / (bumpPrefix + ".cdat"), + outputDir / (bumpPrefix + ".csc"), parameterDelta); + writeSensitivityFile( + outputDir / (prefix + "_basecase_" + suffix + ".gdat"), + outputDir / (bumpPrefix + ".gdat"), + outputDir / (bumpPrefix + ".gsc"), parameterDelta); + if (verbose) { std::cerr << "[bng_cpp] LinearParameterSensitivity: completed bump for '" << paramName << "' (" << paramValue << " -> " << newValue << ")\n"; @@ -1836,35 +3122,81 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou if (actionName == "writefile") { ensureNetwork(); auto format = lowercase(stripQuotes(readArgument(action, "format", ""))); - auto suffix = stripQuotes(readArgument(action, "suffix", "")); - if (format.empty()) format = suffix; + if (format.empty()) format = "net"; + + std::string extension; if (format == "net") { - writeCurrentNetwork(); - } else if (format == "xml") { - const auto xmlContent = io::XmlWriter::write(model, &(*network)); - const auto outputPath = sourcePath.parent_path() / (sourcePath.stem().string() + ".xml"); - std::ofstream outFile(outputPath); - if (outFile) outFile << xmlContent; - } else if (format == "sbml") { - const auto sbmlContent = io::SbmlWriter::write(model, &(*network)); - const auto outputPath = sourcePath.parent_path() / (sourcePath.stem().string() + ".xml"); - std::ofstream outFile(outputPath); - if (outFile) outFile << sbmlContent; - } else if (format == "m" || format == "matlab") { - const auto mContent = io::MatlabWriter::write(model, *network); - const auto outputPath = sourcePath.parent_path() / (sourcePath.stem().string() + "_cvode.m"); - std::ofstream outFile(outputPath); - if (outFile) outFile << mContent; + extension = ".net"; + } else if (format == "xml" || format == "sbml") { + extension = ".xml"; } else if (format == "bngl") { - const auto bnglContent = io::BnglWriter::write(model); - const auto outputPath = sourcePath.parent_path() / (sourcePath.stem().string() + ".bngl"); - std::ofstream outFile(outputPath); - if (outFile) outFile << bnglContent; + extension = ".bngl"; + } else if (format == "ssc") { + extension = ".rxn"; } else { throw std::runtime_error("writeFile: unknown format '" + format + "'"); } + + auto prefixText = stripQuotes( + readArgument(action, "prefix", sourcePath.stem().string())); + if (prefixText.empty()) { + prefixText = sourcePath.stem().string(); + } + std::filesystem::path outputPrefix(prefixText); + if (!outputPrefix.is_absolute()) { + outputPrefix = sourcePath.parent_path() / outputPrefix; + } + const auto suffix = stripQuotes(readArgument(action, "suffix", "")); + if (!suffix.empty()) { + outputPrefix += "_" + suffix; + } + const auto outputPath = outputPrefix.string() + extension; + const bool overwrite = parseBoolean( + readArgument(action, "overwrite", "0")); + if (!overwrite && std::filesystem::exists(outputPath)) { + throw std::runtime_error( + "writeFile: file exists: " + outputPath + + "; set overwrite=>1 to replace it"); + } + + const bool evaluateExpressions = parseBoolean( + readArgument(action, "evaluate_expressions", "0")); + if (format == "net") { + io::NetWriterOptions options; + options.evaluateExpressions = evaluateExpressions; + writeNetworkAt(outputPath, options); + } else { + std::string content; + if (format == "xml") { + content = io::XmlWriter::write(model, &(*network)); + } else if (format == "sbml") { + io::SbmlWriter::Options options; + options.level = 2; + options.version = 3; + options.networksExport = true; + content = io::SbmlWriter::write(model, &(*network), options); + } else if (format == "bngl") { + io::BnglWriter::Options options; + options.evaluateExpressions = evaluateExpressions; + content = io::BnglWriter::write(model, &(*network), options); + } else { + content = io::SscWriter::write(model, *network); + } + + std::ofstream outFile(outputPath); + if (!outFile) { + throw std::runtime_error( + "writeFile: failed to open output file: " + outputPath); + } + outFile << content; + if (!outFile) { + throw std::runtime_error( + "writeFile: failed to write output file: " + outputPath); + } + } if (verbose) { - std::cerr << "[bng_cpp] writeFile format=" << format << "\n"; + std::cerr << "[bng_cpp] writeFile format=" << format + << " path=" << outputPath << "\n"; } continue; } @@ -2176,13 +3508,8 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou fileSuffix = "_viz_opts"; } else { - // Unsupported type - fall back to contact map - std::cerr << "WARNING: visualize type '" << vizType - << "' not yet supported, falling back to contactmap.\n"; - auto contactMap = io::ContactMapWriter::buildContactMap(model); - content = io::ContactMapWriter::toGML(contactMap); - extension = ".gml"; - fileSuffix = "_contact"; + throw std::runtime_error( + "visualize: unsupported visualization type '" + vizType + "'"); } const auto outputPath = sourcePath.parent_path() / (sourcePath.stem().string() + fileSuffix + extension); @@ -2255,134 +3582,13 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou if (actionName == "simulate_protocol") { const auto& protocol = model.getSimulationProtocol(); if (protocol.empty()) { - if (verbose) { - std::cerr << "[bng_cpp] simulate_protocol: no protocol block defined, skipping\n"; - } - continue; + throw std::runtime_error( + "simulate_protocol requires a non-empty protocol block"); } if (verbose) { std::cerr << "[bng_cpp] simulate_protocol: dispatching " << protocol.size() << " protocol actions\n"; } - for (const auto& protoAction : protocol) { - std::string protoName = lowercase(protoAction.name); - if (protoName == "simulate" || protoName == "simulate_ode" || protoName == "simulate_ssa" || - protoName == "simulate_pla" || protoName == "simulate_nf") { - ensureNetwork(); - ast::Action actualAction = protoAction; - if (!lastSimulationState.empty()) { - actualAction.arguments["continue"] = "1"; - } - runSimulation(model, actualAction, sourcePath, *network, verbose, lastSimulationState, lastSimulationEndTime); - } else if (protoName == "setparameter") { - auto target = stripQuotes(readArgument(protoAction, "target", "")); - auto valueText = stripQuotes(readArgument(protoAction, "value", "")); - if (!target.empty() && !valueText.empty()) { - double val = parseScalarValue(valueText, model); - model.getParameters().add(ast::Parameter(target, ast::Expression::number(val))); - model.getParameters().evaluateAll(); - if (network.has_value()) { - std::vector savedAmounts = lastSimulationState; - network = generator.generate(sourcePath); - if (!savedAmounts.empty()) { - for (std::size_t i = 0; i < network->species.size() && i < savedAmounts.size(); ++i) { - network->species.get(i).setAmount(savedAmounts[i]); - } - } - } - } - } else if (protoName == "setconcentration") { - ensureNetwork(); - auto target = readArgument(protoAction, "target", ""); - auto valueText = readArgument(protoAction, "value", ""); - if (!target.empty() && !valueText.empty()) { - double val = parseScalarValue(valueText, model); - const auto found = findSpeciesIndex(*network, target); - if (found.has_value()) { - network->species.get(*found).setAmount(val); - } - } - } else if (protoName == "addconcentration" || protoName == "add_concentration") { - ensureNetwork(); - auto target = readArgument(protoAction, "target", ""); - auto valueText = readArgument(protoAction, "value", ""); - if (!target.empty() && !valueText.empty()) { - double val = parseScalarValue(valueText, model); - const auto found = findSpeciesIndex(*network, target); - if (found.has_value()) { - double current = network->species.get(*found).getAmount(); - network->species.get(*found).setAmount(current + val); - } - } - } else if (protoName == "saveparameters" || protoName == "save_parameters") { - const auto label = stripQuotes(readArgument(protoAction, "value", "default")); - std::unordered_map snapshot; - for (const auto& param : model.getParameters().all()) { - snapshot[param.getName()] = param.getValue(); - } - savedParameters[label] = snapshot; - } else if (protoName == "resetparameters" || protoName == "reset_parameters") { - const auto label = stripQuotes(readArgument(protoAction, "value", "default")); - const auto found = savedParameters.find(label); - if (found != savedParameters.end()) { - for (const auto& [name, val] : found->second) { - model.getParameters().add(ast::Parameter(name, ast::Expression::number(val))); - } - model.getParameters().evaluateAll(); - if (network.has_value()) { - std::vector savedAmounts; - if (!lastSimulationState.empty()) { - savedAmounts = lastSimulationState; - } - network = generator.generate(sourcePath); - if (!savedAmounts.empty()) { - for (std::size_t i = 0; i < network->species.size() && i < savedAmounts.size(); ++i) { - network->species.get(i).setAmount(savedAmounts[i]); - } - } - } - } - } else if (protoName == "saveconcentrations" || protoName == "save_concentrations") { - ensureNetwork(); - const auto label = stripQuotes(readArgument(protoAction, "value", "default")); - savedConcentrations[label] = snapshotConcentrations(*network); - } else if (protoName == "resetconcentrations" || protoName == "reset_concentrations") { - ensureNetwork(); - const auto label = stripQuotes(readArgument(protoAction, "value", "default")); - const auto found = savedConcentrations.find(label); - if (found != savedConcentrations.end()) { - restoreConcentrations(*network, found->second); - } else { - const auto& seeds = model.getSeedSpecies(); - for (std::size_t i = 0; i < network->species.size(); ++i) { - if (i < seeds.size()) { - try { - const double amount = seeds[i].getAmount().evaluate( - [&](const std::string& name) { return model.getParameters().evaluate(name); }); - network->species.get(i).setAmount(amount); - } catch (...) { - network->species.get(i).setAmount(0.0); - } - } else { - network->species.get(i).setAmount(0.0); - } - } - } - } - } - continue; - } - - // Action aliases for BNG2 compatibility - if (actionName == "readnetwork") { - // Alias for include_network / readFile with .net - const auto netFile = stripQuotes(readArgument(action, "file", "")); - if (!netFile.empty()) { - auto netPath = sourcePath.parent_path() / netFile; - if (std::filesystem::exists(netPath)) { - auto parseResult = io::NetReader::parse(netPath); - if (verbose) std::cerr << "[bng_cpp] readNetwork: loaded " << netPath << "\n"; - } - } + runProtocol(std::nullopt); continue; } @@ -2392,22 +3598,6 @@ void ActionDispatch::execute(ast::Model& model, const std::filesystem::path& sou continue; } - if (actionName == "readmodel") { - // readModel: re-read a BNGL file — delegate to include_model semantics - const auto modelFile = stripQuotes(readArgument(action, "file", "")); - if (!modelFile.empty()) { - auto modelPath = sourcePath.parent_path() / modelFile; - if (std::filesystem::exists(modelPath)) { - auto newModel = bng::parser::parseModelFromFile(modelPath.string()); - model.merge(*newModel); - model.getParameters().evaluateAll(); - network.reset(); - if (verbose) std::cerr << "[bng_cpp] readModel: loaded " << modelPath << "\n"; - } - } - continue; - } - if (actionName.rfind("write", 0) == 0) { ensureNetwork(); writeCurrentNetwork(); diff --git a/cpp/ast/MacroBNGModel.cpp b/cpp/ast/MacroBNGModel.cpp index bf0c6168..52e1de16 100644 --- a/cpp/ast/MacroBNGModel.cpp +++ b/cpp/ast/MacroBNGModel.cpp @@ -6,9 +6,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -502,9 +504,10 @@ std::string MacroBNGModel::pre_macr(const std::string& param_prefix) { // Pre-process rules: binding analysis, hierarchy, macro transform pre_rules(nm_site, nm2_site, param_prefix, skf, dpp_site, lis1h); - // pre_obs1 will be called by the second-half agent - // (For completeness of the first half we omit pre_obs1 here since it is - // not in the assignment list; the second half agent will add it.) + // Transform observables and species after the rule dependency maps exist. + // This is the source MacroBNGModel.pm pre_obs1 call; omitting it leaves + // the generated macro model without its transformed species contract. + pre_obs1(nm_site, nm2_site, param_prefix, skf, dpp_site, lis1h); WFILErec_ << " EXIT FROM sub PRE_MACR for *.rec \n"; WFILErec_ << " BEGIN COR_NET \n"; @@ -1180,20 +1183,22 @@ std::string MacroBNGModel::num_site(const std::string& re, const std::string& pr // Extract contents inside parentheses from reactant std::string name; - std::smatch m; - std::regex re_paren("[\\(](.*)[\\)]"); std::string ss_re; - if (std::regex_search(re, m, re_paren)) { - name = m.prefix().str(); - ss_re = m[1].str(); + const std::size_t re_open = re.find('('); + const std::size_t re_close = re.rfind(')'); + if (re_open != std::string::npos && re_close != std::string::npos && re_close > re_open) { + name = re.substr(0, re_open); + ss_re = re.substr(re_open + 1, re_close - re_open - 1); } auto rem = split(ss_re, ','); // Extract contents from product std::string ss_pr; - if (std::regex_search(pr, m, re_paren)) { - ss_pr = m[1].str(); + const std::size_t pr_open = pr.find('('); + const std::size_t pr_close = pr.rfind(')'); + if (pr_open != std::string::npos && pr_close != std::string::npos && pr_close > pr_open) { + ss_pr = pr.substr(pr_open + 1, pr_close - pr_open - 1); } auto prm = split(ss_pr, ','); @@ -1464,8 +1469,8 @@ void MacroBNGModel::pre_rules( // Determine minimal variable set min_set(dpp_site, lis1h, nm_site, nm2_site, site_lig); - // Transform reaction rules (trans_rec is in the second half) - // trans_rec(dpp_site, lis1h, lis1, site_lig); + // Transform reaction rules into macro rules. + trans_rec(dpp_site, lis1h, lis1, site_lig); WFILErules_ << "end reaction_rules\n"; WFILErules_.close(); @@ -1676,7 +1681,121 @@ void MacroBNGModel::r_mac( } // --------------------------------------------------------------------------- -// 23. pre_obs1() (Perl lines 1390-1441) +// 23. trans_specie() (Perl lines 1625-1705) +// --------------------------------------------------------------------------- + +void MacroBNGModel::trans_specie( + std::map>& dpp_site, + std::map>& /*lis1h*/, + const std::vector& lis1) { + // Perl stores these as numeric values through +=. Keep the same + // aggregation semantics while formatting integral counts without a + // spurious decimal suffix in the generated species block. + auto numeric_count = [&](const std::string& token) { + if (token.empty()) return 0.0; + std::string value = token; + if (!std::isdigit(static_cast(token.front()))) { + auto it = para1ms_.find(token); + if (it == para1ms_.end()) return 0.0; + value = it->second; + } + try { + return std::stod(value); + } catch (const std::exception&) { + return 0.0; + } + }; + + auto format_count = [](double value) { + std::ostringstream out; + if (std::isfinite(value) && value == std::trunc(value)) { + out << std::fixed << std::setprecision(0) << value; + } else { + out << std::setprecision(15) << value; + } + return out.str(); + }; + + for (const auto& line : lis1) { + auto parts = split(line, ';'); + // del_blank() produces a leading semicolon. Ignore that empty field, + // matching Perl's (undef,@re)=split(/\;/,$stt). + const std::size_t offset = (!parts.empty() && parts.front().empty()) ? 1 : 0; + if (parts.size() <= offset) continue; + + const std::string& reactant = parts[offset]; + if (reactant.find('(') == std::string::npos) { + std::string passthrough = replaceAll(line, ";", " "); + WFILErec_ << passthrough << "\n"; + WFILEspec2_ << passthrough << "\n"; + continue; + } + + const auto open = reactant.find('('); + const std::string key_obs = reactant.substr(0, open); + std::string sites = reactant.substr(open + 1); + if (!sites.empty() && sites.back() == ')') sites.pop_back(); + + auto raw_sites = split(sites, ','); + auto normalized_sites = raw_sites; + for (auto& site : normalized_sites) { + const auto modifier = site.find_first_of("!~"); + if (modifier != std::string::npos) site.erase(modifier); + } + + std::map transformed_patterns; + std::map transformed_counts; + bool is_dependent = false; + + for (const auto& [dependency_key, dependency_sites] : dpp_site) { + const auto colon = dependency_key.find(':'); + const std::string key_d = (colon == std::string::npos) + ? dependency_key + : dependency_key.substr(0, colon); + if (key_obs != key_d) continue; + + auto sorted_sites = dependency_sites; + std::sort(sorted_sites.begin(), sorted_sites.end()); + + std::string macro_key = key_obs; + std::string transformed; + for (const auto& site : sorted_sites) { + macro_key += "_" + site; + const int index = inc_elt(site, normalized_sites); + if (index > 0 && static_cast(index) <= raw_sites.size()) { + if (!transformed.empty()) transformed += ','; + transformed += raw_sites[static_cast(index - 1)]; + } + } + + if (!transformed.empty()) { + transformed_patterns[macro_key] = transformed; + if (parts.size() > offset + 1) { + transformed_counts[macro_key] += numeric_count(parts[offset + 1]); + } + is_dependent = true; + } + } + + if (!is_dependent) { + std::string passthrough = replaceAll(line, ";", " "); + WFILErec_ << passthrough << "\n"; + WFILEspec2_ << passthrough << "\n"; + continue; + } + + for (const auto& [macro_key, transformed] : transformed_patterns) { + std::string macro_line = macro_key + "(" + transformed + ");" + + format_count(transformed_counts[macro_key]); + macro_line = replaceAll(macro_line, ";", " "); + WFILErec_ << macro_line << "\n"; + WFILEspec2_ << macro_line << "\n"; + } + } +} + +// --------------------------------------------------------------------------- +// 24. pre_obs1() (Perl lines 1390-1441) // --------------------------------------------------------------------------- void MacroBNGModel::pre_obs1( @@ -1745,7 +1864,7 @@ void MacroBNGModel::pre_obs1( } // --------------------------------------------------------------------------- -// 24. trans_obs() (Perl lines 1243-1301) +// 25. trans_obs() (Perl lines 1243-1301) // --------------------------------------------------------------------------- void MacroBNGModel::trans_obs( @@ -1831,7 +1950,7 @@ void MacroBNGModel::trans_obs( } // --------------------------------------------------------------------------- -// 25. obs_mac0() (Perl lines 1444-1520) +// 26. obs_mac0() (Perl lines 1444-1520) // --------------------------------------------------------------------------- std::string MacroBNGModel::obs_mac0( diff --git a/cpp/ast/Model.cpp b/cpp/ast/Model.cpp index 10246e6e..258925ca 100644 --- a/cpp/ast/Model.cpp +++ b/cpp/ast/Model.cpp @@ -172,6 +172,24 @@ const std::vector& Model::getMoleculeTypes() const { return moleculeTypes_; } +MoleculeType* Model::findMoleculeType(const std::string& name) { + for (auto& moleculeType : moleculeTypes_) { + if (moleculeType.getName() == name) { + return &moleculeType; + } + } + return nullptr; +} + +const MoleculeType* Model::findMoleculeType(const std::string& name) const { + for (const auto& moleculeType : moleculeTypes_) { + if (moleculeType.getName() == name) { + return &moleculeType; + } + } + return nullptr; +} + const std::vector& Model::getSeedSpecies() const { return seedSpecies_; } diff --git a/cpp/ast/Model.hpp b/cpp/ast/Model.hpp index 71732c6e..ebbc2dd7 100644 --- a/cpp/ast/Model.hpp +++ b/cpp/ast/Model.hpp @@ -61,6 +61,8 @@ class Model { const std::vector& getEnergyPatterns() const; const std::vector& getObservables() const; const std::vector& getMoleculeTypes() const; + MoleculeType* findMoleculeType(const std::string& name); + const MoleculeType* findMoleculeType(const std::string& name) const; const std::vector& getSeedSpecies() const; const std::vector& getReactionRules() const; std::vector& getReactionRules(); diff --git a/cpp/ast/MoleculeType.cpp b/cpp/ast/MoleculeType.cpp index 5ff24e79..34dafe6b 100644 --- a/cpp/ast/MoleculeType.cpp +++ b/cpp/ast/MoleculeType.cpp @@ -1,5 +1,7 @@ #include "MoleculeType.hpp" +#include +#include #include namespace bng::ast { @@ -19,4 +21,48 @@ bool MoleculeType::isPopulation() const { return population_; } +void MoleculeType::mergeInferredComponents(const std::vector& components) { + std::map incomingCounts; + std::map> mergedStates; + + for (const auto& component : components_) { + auto& states = mergedStates[component.name]; + for (const auto& state : component.allowedStates) { + if (std::find(states.begin(), states.end(), state) == states.end()) { + states.push_back(state); + } + } + } + for (const auto& component : components) { + ++incomingCounts[component.name]; + auto& states = mergedStates[component.name]; + for (const auto& state : component.allowedStates) { + if (std::find(states.begin(), states.end(), state) == states.end()) { + states.push_back(state); + } + } + } + + std::map currentCounts; + for (auto& component : components_) { + const auto states = mergedStates.find(component.name); + if (states != mergedStates.end()) { + component.allowedStates = states->second; + } + ++currentCounts[component.name]; + } + + for (const auto& [name, incomingCount] : incomingCounts) { + const auto currentCount = currentCounts[name]; + const auto requestedCount = std::max(currentCount, incomingCount); + if (requestedCount <= currentCount) { + continue; + } + const auto& states = mergedStates[name]; + for (std::size_t index = currentCount; index < requestedCount; ++index) { + components_.push_back(ComponentType {name, states}); + } + } +} + } // namespace bng::ast diff --git a/cpp/ast/MoleculeType.hpp b/cpp/ast/MoleculeType.hpp index 1099ad12..12ff4e7a 100644 --- a/cpp/ast/MoleculeType.hpp +++ b/cpp/ast/MoleculeType.hpp @@ -18,6 +18,10 @@ class MoleculeType { const std::vector& getComponents() const; bool isPopulation() const; + /// Extend an inferred type with components/states observed in later + /// patterns. Explicit molecule types are never normalized this way. + void mergeInferredComponents(const std::vector& components); + private: std::string name_; std::vector components_; diff --git a/cpp/ast/PopulationMappingRule.cpp b/cpp/ast/PopulationMappingRule.cpp index 3cf7b0e3..00c0a8b9 100644 --- a/cpp/ast/PopulationMappingRule.cpp +++ b/cpp/ast/PopulationMappingRule.cpp @@ -18,6 +18,7 @@ #include "core/BNGcore.hpp" #include "PatternGraphBuilder.hpp" +#include "parser/antlr_compat.hpp" #include #include "BNGLexer.h" #include "BNGParser.h" diff --git a/cpp/ast/ReactionRule.cpp b/cpp/ast/ReactionRule.cpp index 1d757ba9..a93bddf6 100644 --- a/cpp/ast/ReactionRule.cpp +++ b/cpp/ast/ReactionRule.cpp @@ -21,6 +21,7 @@ #include "core/Ullmann.hpp" #include "Model.hpp" +#include "parser/antlr_compat.hpp" #include #include "BNGLexer.h" #include "BNGParser.h" @@ -491,11 +492,15 @@ std::vector splitIntoSpeciesGraphs(const BNGcore::PatternGraph& gr return products; } -void safeDeleteNode(BNGcore::PatternGraph& graph, BNGcore::Node* node) { +void safeDeleteNode(BNGcore::PatternGraph& graph, BNGcore::Node* node, + bool restoreUnboundMarkers = false, + const BNGcore::Node* moleculeBeingDeleted = nullptr) { if (node == nullptr) { return; } + const bool isBoundBond = isBondNode(*node) && node->get_state() == BNGcore::BOUND_STATE; + std::vector incoming; std::vector outgoing; for (auto edge = node->edges_in_begin(); edge != node->edges_in_end(); ++edge) { @@ -512,6 +517,49 @@ void safeDeleteNode(BNGcore::PatternGraph& graph, BNGcore::Node* node) { graph.delete_edge(node, dst); } graph.delete_node(node); + + // BNG2 represents a free component without an edge marker. BNG3's + // in-memory species graphs use an explicit UNBOUND bond node so that the + // matcher can distinguish free sites from unspecified pattern sites. + // Restore that marker on surviving endpoints when a bound bond is removed. + if (restoreUnboundMarkers && isBoundBond) { + for (auto* component : incoming) { + if (!isComponentNode(*component)) { + continue; + } + if (moleculeBeingDeleted != nullptr) { + bool belongsToDeletedMolecule = false; + for (auto edge = component->edges_in_begin(); edge != component->edges_in_end(); ++edge) { + if (*edge == moleculeBeingDeleted) { + belongsToDeletedMolecule = true; + break; + } + } + if (belongsToDeletedMolecule) { + continue; + } + } + bool hasBoundBond = false; + bool hasUnboundMarker = false; + for (auto edge = component->edges_out_begin(); edge != component->edges_out_end(); ++edge) { + if (!isBondNode(**edge)) { + continue; + } + if ((*edge)->get_state() == BNGcore::BOUND_STATE) { + hasBoundBond = true; + } else if ((*edge)->get_state() == BNGcore::UNBOUND_STATE) { + hasUnboundMarker = true; + } + } + if (hasBoundBond || hasUnboundMarker) { + continue; + } + BNGcore::Node unboundNode(BNGcore::BOND_NODE_TYPE); + auto* marker = graph.add_node(unboundNode); + marker->set_state(BNGcore::UNBOUND_STATE); + graph.add_edge(component, marker); + } + } } void deleteMappedMolecule(BNGcore::Map& aggregateMap, BNGcore::Node* sourceMoleculeNode) { @@ -532,7 +580,7 @@ void deleteMappedMolecule(BNGcore::Map& aggregateMap, BNGcore::Node* sourceMolec bondNodes.push_back(*edge); } for (auto* bondNode : bondNodes) { - safeDeleteNode(*graph, bondNode); + safeDeleteNode(*graph, bondNode, true, targetMolecule); } safeDeleteNode(*graph, component); } @@ -859,6 +907,18 @@ bool verifyBondTopology(const BNGcore::PatternGraph& graph) { } // namespace +// Pattern metadata points into the immutable rule pattern graphs. Keeping it +// per rule avoids rebuilding the same molecule/component descriptions for +// every embedding search and generated reaction. +struct ReactionRule::PatternCache { + std::vector reactantInfo; + std::vector productInfo; +}; + +ReactionRule::~ReactionRule() = default; +ReactionRule::ReactionRule(ReactionRule&&) noexcept = default; +ReactionRule& ReactionRule::operator=(ReactionRule&&) noexcept = default; + bool ReactionRule::ComponentRef::operator==(const ComponentRef& other) const { return std::tie(patternIndex, moleculeIndex, componentIndex) == std::tie(other.patternIndex, other.moleculeIndex, other.componentIndex); @@ -963,6 +1023,10 @@ ReactionRule::getNewMoleculeBonds() const { } void ReactionRule::initialize() { + patternCache_ = std::make_unique(); + patternCache_->reactantInfo = describePatterns(reactantPatterns_); + patternCache_->productInfo = describePatterns(productPatterns_); + operations_.clear(); moleculeMappings_.clear(); componentMappings_.clear(); @@ -971,12 +1035,34 @@ void ReactionRule::initialize() { reactionCenter_.assign(reactantPatterns_.size(), {}); patternMatches_.assign(reactantPatterns_.size(), {}); matchesInitialized_ = true; - if (reactantPatterns_.empty() || productPatterns_.empty()) { + if (reactantPatterns_.empty()) { + // Zero-order synthesis has no reactant-side graph to diff against, but + // each product molecule still needs an explicit creation operation. + // Besides driving direct AST expansion, XmlWriter uses these operations + // to serialize the same product construction for the compatibility + // loader. + if (!productPatterns_.empty()) { + const auto& productInfo = patternCache_->productInfo; + const auto productMolecules = flattenMolecules(productInfo); + for (const auto& productMolecule : productMolecules) { + operations_.push_back(TransformOp { + TransformOp::Type::AddMolecule, + {}, + {}, + productMolecule.base.moleculeIndex, + productMolecule.base.patternIndex, + {}, + }); + } + } + return; + } + if (productPatterns_.empty()) { return; } - const auto reactantInfo = describePatterns(reactantPatterns_); - const auto productInfo = describePatterns(productPatterns_); + const auto& reactantInfo = patternCache_->reactantInfo; + const auto& productInfo = patternCache_->productInfo; const auto reactantMolecules = flattenMolecules(reactantInfo); const auto productMolecules = flattenMolecules(productInfo); @@ -1303,7 +1389,7 @@ std::vector ReactionRule::findEmbeddingsForSpecie const Model* model) const { std::vector results; const auto& pattern = reactantPatterns_.at(patternIndex).getGraph(); - const auto reactantInfo = describePatterns(reactantPatterns_); + const auto& reactantInfo = patternCache_->reactantInfo; std::unordered_set seen; const bool debug = std::getenv("BNG_DEBUG_EMBEDDINGS") != nullptr; @@ -1569,8 +1655,17 @@ std::size_t ReactionRule::expandRule( if (productFilter && !productFilter(sg)) { return 0; } - productLabels.push_back(sg.canonicalLabel()); - const auto [index, isNew] = speciesList.add(Species(sg, 0.0, false, productPattern.getCompartment())); + auto product = Species(std::move(sg), 0.0, false, productPattern.getCompartment()); + std::string exact; + const auto exactIndex = speciesList.findExact(product, exact); + std::size_t index; + if (exactIndex) { + index = *exactIndex; + } else { + product.getSpeciesGraph().canonicalLabel(); + index = speciesList.addWithExactKey(std::move(product), std::move(exact)).first; + } + productLabels.push_back(speciesList.get(index).getSpeciesGraph().canonicalLabel()); productIndices.push_back(index); } std::sort(productLabels.begin(), productLabels.end()); @@ -1838,8 +1933,8 @@ bool ReactionRule::buildReaction( const std::function& productFilter, const Model* model) const { const bool debug = std::getenv("BNG_DEBUG_BUILD_RXN") != nullptr; - const auto reactantInfo = describePatterns(reactantPatterns_); - const auto productInfo = describePatterns(productPatterns_); + const auto& reactantInfo = patternCache_->reactantInfo; + const auto& productInfo = patternCache_->productInfo; std::vector reactantIndices; reactantIndices.reserve(matchSet.size()); std::string inferredCompartment; @@ -1960,15 +2055,7 @@ bool ReactionRule::buildReaction( if (lhsComponent == nullptr || rhsComponent == nullptr || bondNode == nullptr) { return false; } - safeDeleteNode(*graph, bondNode); - auto* lhsBond = new BNGcore::Node(BNGcore::BOND_NODE_TYPE); - lhsBond->set_state(BNGcore::UNBOUND_STATE); - graph->add_node(lhsBond); - graph->add_edge(lhsComponent, lhsBond); - auto* rhsBond = new BNGcore::Node(BNGcore::BOND_NODE_TYPE); - rhsBond->set_state(BNGcore::UNBOUND_STATE); - graph->add_node(rhsBond); - graph->add_edge(rhsComponent, rhsBond); + safeDeleteNode(*graph, bondNode, true); break; } case TransformOp::Type::ChangeState: { @@ -2010,7 +2097,7 @@ bool ReactionRule::buildReaction( bondNodes.push_back(*edge); } for (auto* bondNode : bondNodes) { - safeDeleteNode(*graph, bondNode); + safeDeleteNode(*graph, bondNode, true, mappedMolecule); } safeDeleteNode(*graph, component); } @@ -2394,7 +2481,7 @@ bool ReactionRule::buildReaction( // should leave the other half of the complex as a product. // Delete matched molecules from aggregate graph - const auto reactantInfoLocal = describePatterns(reactantPatterns_); + const auto& reactantInfoLocal = patternCache_->reactantInfo; for (std::size_t pi = 0; pi < reactantPatterns_.size(); ++pi) { for (const auto& molInfo : reactantInfoLocal[pi].molecules) { auto* targetMol = matchSet[pi].map.mapf(molInfo.node); @@ -2414,7 +2501,7 @@ bool ReactionRule::buildReaction( bondNodes.push_back(*edge); } for (auto* bondNode : bondNodes) { - safeDeleteNode(aggregateGraph, bondNode); + safeDeleteNode(aggregateGraph, bondNode, true, clonedMol); } safeDeleteNode(aggregateGraph, component); } @@ -2427,7 +2514,6 @@ bool ReactionRule::buildReaction( if (productFilter && !productFilter(productGraph)) { return false; } - productLabels.push_back(productGraph.canonicalLabel()); std::string compartmentToUse; if (!g_compartmentDimensions.empty()) { std::set surfaces, volumes; @@ -2458,8 +2544,17 @@ bool ReactionRule::buildReaction( } } } - auto prodSp = Species(productGraph, 0.0, false, compartmentToUse); - const auto [index, wasNew] = speciesList.add(std::move(prodSp)); + auto prodSp = Species(std::move(productGraph), 0.0, false, compartmentToUse); + std::string exact; + const auto exactIndex = speciesList.findExact(prodSp, exact); + std::size_t index; + if (exactIndex) { + index = *exactIndex; + } else { + prodSp.getSpeciesGraph().canonicalLabel(); + index = speciesList.addWithExactKey(std::move(prodSp), std::move(exact)).first; + } + productLabels.push_back(speciesList.get(index).getSpeciesGraph().canonicalLabel()); productIndices.push_back(index); } } @@ -2640,7 +2735,6 @@ bool ReactionRule::buildReaction( if (productFilter && !productFilter(productGraph)) { return false; } - productLabels.push_back(productGraph.canonicalLabel()); // Perl-faithful inferSpeciesCompartment (SpeciesGraph.pm:795-893): // Collect unique 2D surfaces and 3D volumes from molecule compartments. // 0 surfaces: 1 volume → that volume; 0 volumes → undefined; >1 → first alphabetically @@ -2728,8 +2822,17 @@ bool ReactionRule::buildReaction( // GPCR(l,b!1,loc~cyt,s~P).Arrestin(b!1) from applying to species where // GPCR has 'l' bonded: the product pattern says 'l' should be unbound. // (Product pattern bond constraint check would go here in a future version) - auto prodSp = Species(productGraph, 0.0, false, compartmentToUse); - const auto [index, wasNew] = speciesList.add(std::move(prodSp)); + auto prodSp = Species(std::move(productGraph), 0.0, false, compartmentToUse); + std::string exact; + const auto exactIndex = speciesList.findExact(prodSp, exact); + std::size_t index; + if (exactIndex) { + index = *exactIndex; + } else { + prodSp.getSpeciesGraph().canonicalLabel(); + index = speciesList.addWithExactKey(std::move(prodSp), std::move(exact)).first; + } + productLabels.push_back(speciesList.get(index).getSpeciesGraph().canonicalLabel()); productIndices.push_back(index); } } diff --git a/cpp/ast/ReactionRule.hpp b/cpp/ast/ReactionRule.hpp index 43a3bec0..9277fef8 100644 --- a/cpp/ast/ReactionRule.hpp +++ b/cpp/ast/ReactionRule.hpp @@ -70,6 +70,11 @@ class ReactionRule { bool bidirectional, std::vector reactantPatterns = {}, std::vector productPatterns = {}); + ~ReactionRule(); + ReactionRule(ReactionRule&&) noexcept; + ReactionRule& operator=(ReactionRule&&) noexcept; + ReactionRule(const ReactionRule&) = delete; + ReactionRule& operator=(const ReactionRule&) = delete; const std::string& getRuleName() const; const std::string& getLabel() const; @@ -123,6 +128,8 @@ class ReactionRule { bool bidirectional_; std::vector reactantPatterns_; std::vector productPatterns_; + struct PatternCache; + mutable std::unique_ptr patternCache_; std::vector operations_; std::vector> moleculeMappings_; std::map componentMappings_; diff --git a/cpp/ast/SpeciesList.cpp b/cpp/ast/SpeciesList.cpp index 81f5a3b7..761351d1 100644 --- a/cpp/ast/SpeciesList.cpp +++ b/cpp/ast/SpeciesList.cpp @@ -49,6 +49,32 @@ bool SpeciesList::getCheckIso() const { return checkIso_; } +std::optional SpeciesList::findExact(const Species& species) const { + std::string exact; + return findExact(species, exact); +} + +std::optional SpeciesList::findExact( + const Species& species, std::string& exact) const { + exact.clear(); + if (!checkIso_) { + return std::nullopt; + } + + exact = species.getSpeciesGraph().toStringForDedup(); + const auto exactBucket = indicesByExactString_.find(exact); + if (exactBucket == indicesByExactString_.end()) { + return std::nullopt; + } + + for (const auto index : exactBucket->second) { + if (species_[index].getCompartment() == species.getCompartment()) { + return index; + } + } + return std::nullopt; +} + std::pair SpeciesList::add(Species species) { // When check_iso is disabled, skip all dedup and add unconditionally if (!checkIso_) { @@ -58,10 +84,29 @@ std::pair SpeciesList::add(Species species) { return {index, true}; } - const std::string label = species.getSpeciesGraph().canonicalLabel(); + return addChecked(std::move(species), {}, false); +} + +std::pair SpeciesList::addWithExactKey( + Species species, std::string exact) { + // When check_iso is disabled, skip all dedup and add unconditionally + if (!checkIso_) { + const std::size_t index = species_.size(); + species.setIndex(index); + species_.push_back(std::move(species)); + return {index, true}; + } + + return addChecked(std::move(species), std::move(exact), true); +} + +std::pair SpeciesList::addChecked( + Species species, std::string exact, bool hasExact) { // Use compartment-aware string for dedup to distinguish species that differ // only by per-molecule compartments (e.g., Im@CP.NP vs Im@NU.NP). - const std::string exact = species.getSpeciesGraph().toStringForDedup(); + if (!hasExact) { + exact = species.getSpeciesGraph().toStringForDedup(); + } // Fast path 1: exact string match (O(1)) const auto exactBucket = indicesByExactString_.find(exact); @@ -78,6 +123,22 @@ std::pair SpeciesList::add(Species species) { } } + // Canonical labeling is substantially more expensive than exact string + // serialization. Only compute it after the exact-key fast path misses; + // product graphs are frequently exact duplicates of an existing species. + const std::string label = species.getSpeciesGraph().canonicalLabel(); + // The fingerprint is also compartment-aware at the molecule level. It + // is the semantic guard needed when a graph's serialization order differs + // between two otherwise isomorphic products. + const std::string fp = species.getSpeciesGraph().fingerprint(); + // Canonical labeling may change node-index tie breakers used by the + // serializer, so use the canonicalized key for compartmented fallback and + // insert paths. Unscoped species retain the pre-label exact key and avoid + // serializing every new species a second time. + if (!species.getCompartment().empty()) { + exact = species.getSpeciesGraph().toStringForDedup(); + } + // Fast path 2: canonical label match (O(1)) // Note: canonical label is compartment-blind. Species with same graph structure // and same species-level compartment but different per-molecule compartments @@ -90,11 +151,10 @@ std::pair SpeciesList::add(Species species) { if (existingSpecies.getCompartment() != species.getCompartment()) { continue; } - // Per-molecule compartment check: when species have compartments, - // dedup strings must match to avoid merging species that differ - // only in per-molecule compartments (e.g., Im@CP.NP vs Im@NU.NP) - if (!species.getCompartment().empty() && - existingSpecies.getSpeciesGraph().toStringForDedup() != exact) { + // Canonical labels omit molecule compartments. Require the + // compartment-aware fingerprint instead of the serialization key: + // equivalent graphs may list root molecules in different orders. + if (existingSpecies.getSpeciesGraph().fingerprint() != fp) { continue; } if (existingSpecies.getCompartment().empty() && !species.getCompartment().empty()) { @@ -109,7 +169,6 @@ std::pair SpeciesList::add(Species species) { // the same fingerprint. This replaces the old O(n) scan over all species with an // O(1) hash lookup. Only species in the same fingerprint bucket need Ullmann // confirmation (which now correctly handles in-edges for full isomorphism). - const std::string fp = species.getSpeciesGraph().fingerprint(); const auto fpBucket = indicesByFingerprint_.find(fp); if (fpBucket != indicesByFingerprint_.end()) { for (const auto index : fpBucket->second) { @@ -120,10 +179,6 @@ std::pair SpeciesList::add(Species species) { if (!isIsomorphic(existingSpecies.getSpeciesGraph(), species.getSpeciesGraph())) { continue; } - if (!species.getCompartment().empty() && - existingSpecies.getSpeciesGraph().toStringForDedup() != exact) { - continue; - } if (existingSpecies.getCompartment().empty() && !species.getCompartment().empty()) { existingSpecies.setCompartment(species.getCompartment()); } diff --git a/cpp/ast/SpeciesList.hpp b/cpp/ast/SpeciesList.hpp index 7ea6df7c..7fd2c7ca 100644 --- a/cpp/ast/SpeciesList.hpp +++ b/cpp/ast/SpeciesList.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -11,6 +12,11 @@ namespace bng::ast { class SpeciesList { public: std::pair add(Species species); + // Insert using an exact key already computed by findExact(). + std::pair addWithExactKey(Species species, std::string exact); + // Check the exact compartment-aware key without canonicalizing the graph. + std::optional findExact(const Species& species) const; + std::optional findExact(const Species& species, std::string& exact) const; const Species& get(std::size_t index) const; Species& get(std::size_t index); bool containsLabel(const std::string& canonicalLabel) const; @@ -25,6 +31,7 @@ class SpeciesList { bool getCheckIso() const; private: + std::pair addChecked(Species species, std::string exact, bool hasExact); std::vector species_; std::unordered_map> indicesByLabel_; std::unordered_map> indicesByExactString_; diff --git a/cpp/bindings/bind_engine.cpp b/cpp/bindings/bind_engine.cpp index 5413c722..ccef760d 100644 --- a/cpp/bindings/bind_engine.cpp +++ b/cpp/bindings/bind_engine.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include "ast/Model.hpp" @@ -105,7 +106,13 @@ void bind_engine(py::module_& m) { .def_readwrite("method", &OdeOptions::method) .def_readwrite("max_step", &OdeOptions::maxStep) .def_readwrite("steady_state", &OdeOptions::steadyState) - .def_readwrite("steady_state_tol", &OdeOptions::steadyStateTol); + .def_readwrite("steady_state_tol", &OdeOptions::steadyStateTol) + .def_readwrite("stop_if", &OdeOptions::stopIf) + .def_readwrite("sample_times", &OdeOptions::sampleTimes) + .def_readwrite("max_sim_steps", &OdeOptions::maxSimSteps) + .def_readwrite("output_step_interval", &OdeOptions::outputStepInterval) + .def_readwrite("sparse", &OdeOptions::sparse) + .def_readwrite("check_product_scale", &OdeOptions::checkProductScale); m.def("generate_network", [](Model& model, size_t max_iter) { py::gil_scoped_release release; @@ -116,7 +123,18 @@ void bind_engine(py::module_& m) { m.def("simulate_ode", [](Model& model, GeneratedNetwork& network, double t_end, int n_steps, double t_start, - double rtol, double atol, const std::string& method) { + double rtol, double atol, const std::string& method, + double max_step, bool steady_state, + double steady_state_tol, const std::string& stop_if, + const std::vector& sample_times, + std::size_t max_sim_steps, + std::size_t output_step_interval, bool sparse, + double check_product_scale) { + if (max_sim_steps > 0 || output_step_interval > 0) { + throw std::runtime_error( + "max_sim_steps and output_step_interval are supported only " + "for simulate_ssa"); + } py::gil_scoped_release release; OdeOptions opts; @@ -126,6 +144,15 @@ void bind_engine(py::module_& m) { opts.rtol = rtol; opts.atol = atol; opts.method = method; + opts.maxStep = max_step; + opts.steadyState = steady_state; + opts.steadyStateTol = steady_state_tol; + opts.stopIf = stop_if; + opts.sampleTimes = sample_times; + opts.maxSimSteps = max_sim_steps; + opts.outputStepInterval = output_step_interval; + opts.sparse = sparse; + opts.checkProductScale = check_product_scale; OdeIntegrator integrator(model, network); OdeResult result = integrator.integrate(opts); @@ -141,10 +168,23 @@ void bind_engine(py::module_& m) { py::arg("rtol") = 1e-8, py::arg("atol") = 1e-8, py::arg("method") = "cvode", + py::arg("max_step") = 0.0, + py::arg("steady_state") = false, + py::arg("steady_state_tol") = 1e-8, + py::arg("stop_if") = "", + py::arg("sample_times") = std::vector{}, + py::arg("max_sim_steps") = 0, + py::arg("output_step_interval") = 0, + py::arg("sparse") = false, + py::arg("check_product_scale") = 0.0, "Run ODE simulation on a generated network"); m.def("simulate_ssa", [](Model& model, GeneratedNetwork& network, - double t_end, int n_steps, double t_start, int seed) { + double t_end, int n_steps, double t_start, int seed, + const std::string& stop_if, + const std::vector& sample_times, + std::size_t max_sim_steps, + std::size_t output_step_interval) { py::gil_scoped_release release; OdeOptions opts; @@ -153,6 +193,10 @@ void bind_engine(py::module_& m) { opts.nSteps = n_steps; opts.method = "ssa"; opts.seed = seed; + opts.stopIf = stop_if; + opts.sampleTimes = sample_times; + opts.maxSimSteps = max_sim_steps; + opts.outputStepInterval = output_step_interval; OdeIntegrator integrator(model, network); OdeResult result = integrator.integrate(opts); @@ -166,6 +210,10 @@ void bind_engine(py::module_& m) { py::arg("n_steps") = 100, py::arg("t_start") = 0.0, py::arg("seed") = 0, + py::arg("stop_if") = "", + py::arg("sample_times") = std::vector{}, + py::arg("max_sim_steps") = 0, + py::arg("output_step_interval") = 0, "Run SSA simulation on a generated network"); m.def("simulate_pla", [](Model& model, GeneratedNetwork& network, diff --git a/cpp/bindings/bind_io.cpp b/cpp/bindings/bind_io.cpp index 5b194d02..3c4784de 100644 --- a/cpp/bindings/bind_io.cpp +++ b/cpp/bindings/bind_io.cpp @@ -12,6 +12,7 @@ #include "io/SbmlWriter.hpp" #include "io/MatlabWriter.hpp" #include "io/LatexWriter.hpp" +#include "io/MexWriter.hpp" namespace py = pybind11; using namespace bng::ast; @@ -79,4 +80,25 @@ void bind_io(py::module_& m) { out << content; }, py::arg("model"), py::arg("network"), py::arg("path"), "Write model to LaTeX format"); + + io.def("write_mex_string", [](const Model& model, const GeneratedNetwork& network, + double t_start, double t_end, std::size_t n_steps, + double atol, double rtol, int max_num_steps, + double max_step, bool sparse) { + MexWriter::Options options; + options.tStart = t_start; + options.tEnd = t_end; + options.nSteps = n_steps; + options.atol = atol; + options.rtol = rtol; + options.maxNumSteps = max_num_steps; + options.maxStep = max_step; + options.sparse = sparse; + return MexWriter::write(model, network, options); + }, py::arg("model"), py::arg("network"), + py::arg("t_start") = 0.0, py::arg("t_end") = 10.0, + py::arg("n_steps") = 20, py::arg("atol") = 1e-6, + py::arg("rtol") = 1e-8, py::arg("max_num_steps") = 2000, + py::arg("max_step") = 0.0, py::arg("sparse") = false, + "Serialize a generated MEX ODE implementation"); } diff --git a/cpp/bindings/bind_model.cpp b/cpp/bindings/bind_model.cpp index 7ae40a8f..51c69b92 100644 --- a/cpp/bindings/bind_model.cpp +++ b/cpp/bindings/bind_model.cpp @@ -158,6 +158,8 @@ void bind_model(py::module_& m) { }) .def_property_readonly("model_name", &Model::getModelName) .def_property_readonly("version", &Model::getVersion) + .def("set_model_name", &Model::setModelName, + py::arg("name")) .def("set_parameter", [](Model& model, const std::string& name, double value) { auto& params = model.getParameters(); if (!params.contains(name)) { diff --git a/cpp/bindings/bind_nfsim.cpp b/cpp/bindings/bind_nfsim.cpp index 1e547f42..3a438e74 100644 --- a/cpp/bindings/bind_nfsim.cpp +++ b/cpp/bindings/bind_nfsim.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include #include @@ -42,22 +44,55 @@ std::string make_temp_xml_path() { void bind_nfsim(py::module_& m) { - m.def("simulate_nf", [](Model& model, double t_end, int n_steps, - int seed, int equilibrate, bool verbose) -> py::dict { - if (t_end < 0.0) { - throw std::invalid_argument("t_end must be non-negative"); + m.def("simulate_nf", [](Model& model, double t_end, int n_steps, + int seed, double equilibrate, bool verbose, + const std::string& source_path, + const std::vector& sample_times, + int traversal_limit, double t_start) -> py::dict { + if (!std::isfinite(t_start) || !std::isfinite(t_end)) { + throw std::invalid_argument("t_start and t_end must be finite"); } - if (n_steps <= 0) { - throw std::invalid_argument("n_steps must be positive"); + if (t_end < t_start) { + throw std::invalid_argument( + "t_end must be greater than or equal to t_start"); + } + if (n_steps < 0 || (n_steps == 0 && sample_times.empty())) { + throw std::invalid_argument( + "n_steps must be positive unless sample_times is set"); + } + if (!std::isfinite(equilibrate) || equilibrate < 0.0) { + throw std::invalid_argument( + "equilibrate must be finite and non-negative"); + } + if (traversal_limit < -1) { + throw std::invalid_argument( + "traversal_limit must be -1 (automatic) or non-negative"); } - // The direct adapter is deliberately fail-closed: while any section is - // incomplete it returns nullptr and we retain the established - // in-memory XML initializer as the compatibility path. XML is only - // materialized on disk if that compatibility initializer also fails. + std::vector output_times = sample_times; + if (!output_times.empty()) { + for (std::size_t index = 0; index < output_times.size(); ++index) { + const double time = output_times[index]; + if (!std::isfinite(time) || time < t_start || time > t_end || + (index > 0 && time <= output_times[index - 1])) { + throw std::invalid_argument( + "sample_times must be finite, strictly increasing, " + "and within [t_start, t_end]"); + } + } + if (output_times.back() < t_end) { + output_times.push_back(t_end); + } + } + + // The direct adapter is deliberately fail-closed. XML compatibility + // construction is available only when the caller explicitly opts in; + // unsupported direct semantics must not silently change execution + // paths. TempFileGuard tmp_guard; int suggestedTraversalLimit = -1; std::unique_ptr system; + std::string construction_path = "direct"; { py::gil_scoped_release release; @@ -66,10 +101,21 @@ void bind_nfsim(py::module_& m) { false, // blockSameComplexBinding -1, // globalMoleculeLimit (unlimited) verbose, - suggestedTraversalLimit + suggestedTraversalLimit, + fs::path(source_path) )); if (!system) { + if (std::getenv("BNG_NFSIM_REQUIRE_DIRECT") != nullptr) { + throw std::runtime_error( + "NFsim direct AST initialization required but unavailable"); + } + if (std::getenv("BNG_NFSIM_ALLOW_XML_FALLBACK") == nullptr) { + throw std::runtime_error( + "NFsim direct AST initialization unavailable; XML fallback disabled " + "(set BNG_NFSIM_ALLOW_XML_FALLBACK=1 to opt in)"); + } + construction_path = "in-memory-xml"; if (verbose) { std::cerr << "[bind_nfsim] Direct AST initialization unavailable; " "using compatibility XML path...\n"; @@ -88,6 +134,7 @@ void bind_nfsim(py::module_& m) { } if (!system) { + construction_path = "on-disk-xml"; // Preserve the historical on-disk initializer as a last-resort // compatibility mode. This branch is expected to be rare and // is intentionally visible in verbose diagnostics. @@ -123,6 +170,24 @@ void bind_nfsim(py::module_& m) { throw std::runtime_error("Failed to initialize NFSim system from model XML"); } + // NFsim's XML entry point applies the parser's recommended traversal + // depth before preparing reactions. The direct path must do the same + // or large complexes silently fall back to an unrestricted graph walk. + // A caller-supplied non-negative value retains the native -utl escape + // hatch; -1 uses the recommendation computed while building the system. + const int effectiveTraversalLimit = + traversal_limit >= 0 ? traversal_limit : suggestedTraversalLimit; + system->setUniversalTraversalLimit(effectiveTraversalLimit); + if (verbose) { + std::cerr << "[bind_nfsim] Universal traversal limit = " + << effectiveTraversalLimit << "\n"; + } + + // NFsim evaluates time-dependent functions against the system clock. + // Set the absolute API start before preparation so initial propensities + // and later output checkpoints use the same time origin. + system->setCurrentTime(t_start); + // Step 5: Seed per-instance RNG (after system creation, before prepareForSimulation) if (seed > 0) { system->seedRNG(static_cast(seed)); @@ -137,7 +202,7 @@ void bind_nfsim(py::module_& m) { // Step 6: Run equilibration if requested if (equilibrate > 0) { py::gil_scoped_release release; - system->equilibrate(static_cast(equilibrate)); + system->equilibrate(equilibrate); } // Step 7: Collect observable names @@ -147,14 +212,13 @@ void bind_nfsim(py::module_& m) { } int n_obs = static_cast(obs_names.size()); - // Step 8: Run simulation in steps, collecting time-series data - double dt = t_end / static_cast(n_steps); + // Step 8: Run simulation in steps, collecting time-series data. + // NFsim's System supports arbitrary stopping times through stepTo; + // use that path when the caller requests explicit sample times. std::vector time_points; std::vector> obs_series(n_obs); - // Record initial state - time_points.push_back(0.0); - { + const auto record_observables = [&]() { int idx = 0; for (auto* obs : system->getObsToOutput()) { if (obs) { @@ -162,22 +226,31 @@ void bind_nfsim(py::module_& m) { idx++; } } - } + }; - // Simulate in chunks to get time-series { py::gil_scoped_release release; - for (int step = 1; step <= n_steps; ++step) { - double t_current = step * dt; - system->stepTo(t_current); - - time_points.push_back(t_current); - int idx = 0; - for (auto* obs : system->getObsToOutput()) { - if (obs) { - obs_series[idx].push_back(static_cast(obs->getCount())); - idx++; - } + if (output_times.empty()) { + const double dt = (t_end - t_start) / static_cast(n_steps); + double t_current = t_start; + time_points.push_back(t_start); + record_observables(); + for (int step = 1; step <= n_steps; ++step) { + // Match NFsim::sim's repeated checkpoint accumulation. + // Multiplication can round a final boundary differently + // and change whether an event is included at that edge. + t_current += dt; + system->stepTo(t_current, step == n_steps); + time_points.push_back(t_current); + record_observables(); + } + } else { + time_points.reserve(output_times.size()); + for (std::size_t index = 0; index < output_times.size(); ++index) { + const double time = output_times[index]; + system->stepTo(time, index + 1 == output_times.size()); + time_points.push_back(time); + record_observables(); } } } @@ -199,6 +272,7 @@ void bind_nfsim(py::module_& m) { obs_dict[py::cast(obs_names[i])] = arr; } result["observables"] = obs_dict; + result["construction_path"] = construction_path; return result; }, @@ -208,6 +282,10 @@ void bind_nfsim(py::module_& m) { py::arg("seed") = 0, py::arg("equilibrate") = 0, py::arg("verbose") = false, + py::arg("source_path") = "", + py::arg("sample_times") = std::vector{}, + py::arg("traversal_limit") = -1, + py::arg("t_start") = 0.0, "Run network-free (NFSim) simulation on a model.\n\n" "Returns a dict with 'time' (numpy array of time points) and\n" "'observables' (dict of name -> numpy array of values at each time point)."); diff --git a/cpp/bindings/bind_parser.cpp b/cpp/bindings/bind_parser.cpp index 903544eb..bb4fa3cd 100644 --- a/cpp/bindings/bind_parser.cpp +++ b/cpp/bindings/bind_parser.cpp @@ -7,6 +7,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include #include "BNGLexer.h" #include "BNGParser.h" diff --git a/cpp/core/Node.cpp b/cpp/core/Node.cpp index bc3eebe8..6181e95e 100644 --- a/cpp/core/Node.cpp +++ b/cpp/core/Node.cpp @@ -191,9 +191,11 @@ Node::less_by_index ( const Node * node1, const Node * node2 ) std::string Node::get_label ( ) const { - std::stringstream s; - s << index << ":" << type->get_label() << state->get_label(); - return s.str(); + std::string s = std::to_string(index); + s += ":"; + s += type->get_label(); + s += state->get_label(); + return s; } @@ -289,7 +291,7 @@ Node::validate_typing ( bool instance ) const std::string Node::get_BNG2_string ( link_index_t & link_index, int & next_bond ) const { - std::stringstream s; + std::string s; if ( get_type() < LINK_NODE_TYPE ) { // handle a link node @@ -301,12 +303,14 @@ Node::get_BNG2_string ( link_index_t & link_index, int & next_bond ) const if ( it == link_index.end() ) { link_index.insert( std::pair( this, next_bond ) ); - s << "!" << next_bond; + s += "!"; + s += std::to_string(next_bond); ++next_bond; } else { - s << "!" << it->second; + s += "!"; + s += std::to_string(it->second); } } //else @@ -315,7 +319,7 @@ Node::get_BNG2_string ( link_index_t & link_index, int & next_bond ) const else { // handle other node types. // local variables: - std::stringstream t; + std::string t; bool found_entity_child; bool has_entity_parent; @@ -325,9 +329,9 @@ Node::get_BNG2_string ( link_index_t & link_index, int & next_bond ) const std::vector::iterator node_iter; // add type_name - s << get_type().get_BNG2_string( true ); + s += get_type().get_BNG2_string( true ); // add state - s << get_state().get_BNG2_string( ); + s += get_state().get_BNG2_string( ); // sort out_edges using Perl BNG2 convention for components: // 1. By type name (lexical) @@ -396,19 +400,23 @@ Node::get_BNG2_string ( link_index_t & link_index, int & next_bond ) const child_node = *node_iter; if ( child_node->get_type() < ENTITY_NODE_TYPE ) { - if ( found_entity_child ) t << ","; - t << child_node->get_BNG2_string( link_index, next_bond ); + if ( found_entity_child ) t += ","; + t += child_node->get_BNG2_string( link_index, next_bond ); found_entity_child = true; } else if ( child_node->get_type() < LINK_NODE_TYPE ) - s << child_node->get_BNG2_string( link_index, next_bond ); + s += child_node->get_BNG2_string( link_index, next_bond ); } // if there are any children, include in string if ( found_entity_child ) - s << "(" << t.str() << ")"; + { + s += "("; + s += t; + s += ")"; + } else if ( !has_entity_parent ) - s << "()"; + s += "()"; // Per-molecule compartment suffix (@CYT) is NOT added here because it // changes the .net output format and breaks comparison with Perl. @@ -416,5 +424,5 @@ Node::get_BNG2_string ( link_index_t & link_index, int & next_bond ) const // in SpeciesList::add(). } // return string - return s.str(); + return s; } diff --git a/cpp/core/PatternGraph.cpp b/cpp/core/PatternGraph.cpp index f755a644..bd2a17a8 100644 --- a/cpp/core/PatternGraph.cpp +++ b/cpp/core/PatternGraph.cpp @@ -615,6 +615,16 @@ PatternGraph::find_canonical_order ( bool preserve_prior_order ) const if ( !preserve_prior_order ) reset_index(); + // Empty species graphs are valid inputs. They have no Nauty vertex or + // partition array, so canonicalization is the empty label rather than an + // attempt to write ptn[-1]. + if ( nodes.empty() ) + { + label.clear(); + canonical_flag = true; + return; + } + //std::cout << "find_canonical_order" << std::endl; int nv, m, nde; int v_index, e_index; @@ -1231,4 +1241,3 @@ PatternGraph::split_connected ( patterngraph_container_t & split_graphs ) } - diff --git a/cpp/engine/HybridModelGenerator.cpp b/cpp/engine/HybridModelGenerator.cpp index a24ba823..9ee8fca2 100644 --- a/cpp/engine/HybridModelGenerator.cpp +++ b/cpp/engine/HybridModelGenerator.cpp @@ -6,6 +6,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include "antlr4-runtime.h" #include "BNGLexer.h" #include "BNGParser.h" diff --git a/cpp/engine/NetworkGenerator.cpp b/cpp/engine/NetworkGenerator.cpp index b89225db..93bb77c5 100644 --- a/cpp/engine/NetworkGenerator.cpp +++ b/cpp/engine/NetworkGenerator.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include "ast/ReactionRule.hpp" #include "io/NetWriter.hpp" @@ -79,10 +80,17 @@ bool parseBooleanLike(std::string text) { if (text.size() >= 2 && ((text.front() == '"' && text.back() == '"') || (text.front() == '\'' && text.back() == '\''))) { text = text.substr(1, text.size() - 2); } - std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return text == "1" || text == "true" || text == "yes" || text == "on"; + const auto caseInsensitiveEqual = [](const std::string& value, std::string_view expected) { + if (value.size() != expected.size()) return false; + return std::equal(value.begin(), value.end(), expected.begin(), [](char lhs, char rhs) { + return std::tolower(static_cast(lhs)) == + std::tolower(static_cast(rhs)); + }); + }; + return caseInsensitiveEqual(text, "1") || + caseInsensitiveEqual(text, "true") || + caseInsensitiveEqual(text, "yes") || + caseInsensitiveEqual(text, "on"); } std::optional parseMaxAgg(const ast::Model& model) { diff --git a/cpp/engine/OdeIntegrator.cpp b/cpp/engine/OdeIntegrator.cpp index 8c0b6425..59422109 100644 --- a/cpp/engine/OdeIntegrator.cpp +++ b/cpp/engine/OdeIntegrator.cpp @@ -1,13 +1,16 @@ #include "OdeIntegrator.hpp" #include +#include #include #include #include #include #include #include +#include +#include "parser/antlr_compat.hpp" #include "antlr4-runtime.h" #include "BNGLexer.h" #include "BNGParser.h" @@ -118,6 +121,37 @@ bool hasWordBoundaryMatch(const std::string& text, const std::string& target) { return false; } +bool hasWordBoundaryMatchCaseInsensitive(std::string_view text, std::string_view target) { + if (target.empty() || text.length() < target.length()) { + return false; + } + + for (std::size_t i = 0; i <= text.length() - target.length(); ++i) { + bool match = true; + for (std::size_t j = 0; j < target.length(); ++j) { + if (std::tolower(static_cast(text[i + j])) != + std::tolower(static_cast(target[j]))) { + match = false; + break; + } + } + if (!match) { + continue; + } + + const bool leftBoundary = + i == 0 || (!std::isalnum(static_cast(text[i - 1])) && text[i - 1] != '_'); + const bool rightBoundary = + i + target.length() == text.length() || + (!std::isalnum(static_cast(text[i + target.length()])) && + text[i + target.length()] != '_'); + if (leftBoundary && rightBoundary) { + return true; + } + } + return false; +} + } // anonymous namespace @@ -169,6 +203,18 @@ void OdeIntegrator::compile() { } } + // Precompute lowercase function names once instead of allocating and + // transforming them for every reaction/function pair. + std::vector lowerFuncNames; + lowerFuncNames.reserve(model_.getFunctions().size()); + for (const auto& func : model_.getFunctions()) { + std::string name = func.getName(); + std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + lowerFuncNames.push_back(std::move(name)); + } + std::size_t rxnIndex = 0; for (const auto& rxn : network_.reactions.all()) { CompiledReaction crxn; @@ -466,12 +512,25 @@ void OdeIntegrator::compile() { bool isFunctional = crxn.isFunctional; // May already be set by Sat/MM/Hill const auto& rateExpr = rxn.getRateExpression(); + std::string lowerRawRL; + bool lowerRawRLPopulated = false; + const auto ensureLowerRawRL = [&]() { + if (lowerRawRLPopulated) { + return; + } + lowerRawRL = rawRateLaw; + std::transform(lowerRawRL.begin(), lowerRawRL.end(), lowerRawRL.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + lowerRawRLPopulated = true; + }; + + bool checkedFunctions = false; if (!isFunctional && rateExpr.has_value()) { - std::string rateLawLower = rawRateLaw; - std::transform(rateLawLower.begin(), rateLawLower.end(), rateLawLower.begin(), ::tolower); + ensureLowerRawRL(); - if (rateLawLower.find("time") != std::string::npos || - hasWordBoundaryMatch(rateLawLower, "t")) { + if (lowerRawRL.find("time") != std::string::npos || + hasWordBoundaryMatch(lowerRawRL, "t")) { isFunctional = true; } else { // Check for observable dependencies @@ -487,18 +546,18 @@ void OdeIntegrator::compile() { // --- Bug 2 fix: Check for user-defined function references --- std::string matchedFuncName; if (!isFunctional) { - const std::string rawRL = rxn.getRateLaw(); + std::size_t functionIndex = 0; for (const auto& func : model_.getFunctions()) { const auto& fname = func.getName(); - std::string fnameLower = fname; - std::transform(fnameLower.begin(), fnameLower.end(), fnameLower.begin(), ::tolower); - if (hasWordBoundaryMatch(rateLawLower, fnameLower) || - hasWordBoundaryMatch(rawRL, fname)) { + if (hasWordBoundaryMatchCaseInsensitive(rawRateLaw, lowerFuncNames[functionIndex]) || + hasWordBoundaryMatch(rawRateLaw, fname)) { isFunctional = true; matchedFuncName = fname; break; } + ++functionIndex; } + checkedFunctions = true; } if (isFunctional) { @@ -525,20 +584,18 @@ void OdeIntegrator::compile() { // Bug 2 fix: Also check for user-defined function references OUTSIDE the rateExpr block // This catches cases where rateExpr is nullopt but the rate law string references a function - if (!crxn.isFunctional) { - const std::string rawRL = rxn.getRateLaw(); - std::string rlLow = rawRL; - std::transform(rlLow.begin(), rlLow.end(), rlLow.begin(), ::tolower); + if (!crxn.isFunctional && !checkedFunctions) { + ensureLowerRawRL(); + std::size_t functionIndex = 0; for (const auto& func : model_.getFunctions()) { - std::string fnameLow = func.getName(); - std::transform(fnameLow.begin(), fnameLow.end(), fnameLow.begin(), ::tolower); - if (hasWordBoundaryMatch(rlLow, fnameLow) || hasWordBoundaryMatch(rawRL, func.getName())) { + if (hasWordBoundaryMatchCaseInsensitive(rawRateLaw, lowerFuncNames[functionIndex]) || + hasWordBoundaryMatch(rawRateLaw, func.getName())) { crxn.isFunctional = true; // Parse the full rate law string into an expression so that // compound expressions like "k * funcName()" are preserved. ast::Expression funcExpr2 = ast::Expression::number(0.0); try { - funcExpr2 = parser::parseExpression(rawRL); + funcExpr2 = parser::parseExpression(rawRateLaw); } catch (...) { // Fallback: use bare identifier if parsing fails funcExpr2 = ast::Expression::identifier(func.getName()); @@ -559,6 +616,7 @@ void OdeIntegrator::compile() { hasFunctionalRates_ = true; break; } + ++functionIndex; } } @@ -656,8 +714,15 @@ void OdeIntegrator::compile() { // 2. Pre-allocate groupValues for reuse in derivs() groupValues_.resize(compiledGroups_.size(), 0.0); - // 3. Separate constant-rate vs functional-rate reaction indices + // 3. Keep large constant-reaction networks in compact, contiguous arrays + // for derivs(). Small networks retain the original indexed representation + // because their one-time packing cost is not amortized by CVODE callbacks. + constexpr std::size_t compactReactionThreshold = 512; constantRxnIndices_.clear(); + constantReactions_.clear(); + constantReactantIndices_.clear(); + constantProductIndices_.clear(); + useCompactConstantReactions_ = false; functionalRxnIndices_.clear(); for (std::size_t i = 0; i < compiledRxns_.size(); ++i) { if (compiledRxns_[i].isFunctional) { @@ -666,6 +731,28 @@ void OdeIntegrator::compile() { constantRxnIndices_.push_back(i); } } + + if (constantRxnIndices_.size() >= compactReactionThreshold) { + useCompactConstantReactions_ = true; + constantReactions_.reserve(constantRxnIndices_.size()); + constantReactantIndices_.reserve(constantRxnIndices_.size()); + constantProductIndices_.reserve(constantRxnIndices_.size()); + for (const auto idx : constantRxnIndices_) { + const auto& rxn = compiledRxns_[idx]; + CompiledConstantReaction compact; + compact.reactantOffset = constantReactantIndices_.size(); + compact.productOffset = constantProductIndices_.size(); + compact.reactantCount = rxn.reactantIndices.size(); + compact.productCount = rxn.productIndices.size(); + compact.rateConstant = rxn.rateConstant; + compact.isTotalRate = rxn.isTotalRate; + constantReactantIndices_.insert(constantReactantIndices_.end(), + rxn.reactantIndices.begin(), rxn.reactantIndices.end()); + constantProductIndices_.insert(constantProductIndices_.end(), + rxn.productIndices.begin(), rxn.productIndices.end()); + constantReactions_.push_back(compact); + } + } } void OdeIntegrator::compileGroups() { @@ -674,143 +761,182 @@ void OdeIntegrator::compileGroups() { // Note: const_cast needed because buildPatternGraph may infer molecule types auto& mutableModel = const_cast(model_); + struct CachedObservablePattern { + BNGcore::PatternGraph graph; + std::string compartment; + }; + struct ParsedPatternInfo { + const BNGcore::PatternGraph* graph; + std::string compartment; + std::string quantifier; + int quantifierThreshold; + bool hasQuantifier; + }; + std::unordered_map parsedObservableCache; + for (const auto& observable : model_.getObservables()) { CompiledGroup group; group.name = observable.getName(); - // Match each pattern against all species - for (std::size_t speciesIndex = 0; speciesIndex < network_.species.size(); ++speciesIndex) { - std::size_t weight = 0; + std::vector parsedPatterns; + parsedPatterns.reserve(observable.getPatterns().size()); - for (const auto& patternText : observable.getPatterns()) { - // Parse the observable pattern - try { - // Extract optional quantifier (e.g., "> 2", "== 1", "<= 3") - std::string cleanPattern = patternText; - std::string quantOp; - int quantThreshold = 0; - bool hasQuantifier = false; - - // Look for trailing quantifier: operators >=, <=, ==, !=, >, < - // Pattern: species_def WS* (OP WS* INT) - static const std::vector ops = {">=", "<=", "==", "!=", ">", "<"}; - for (const auto& op : ops) { - auto pos = cleanPattern.rfind(op); - if (pos != std::string::npos && pos > 0) { - std::string after = cleanPattern.substr(pos + op.size()); - // Trim whitespace - after.erase(0, after.find_first_not_of(" \t")); - after.erase(after.find_last_not_of(" \t") + 1); - // Check if remainder is an integer - bool isInt = !after.empty(); - for (char c : after) { - if (!std::isdigit(static_cast(c))) { isInt = false; break; } - } - if (isInt) { - // Verify the character before the operator is whitespace or close-paren - // to avoid matching bond syntax like "!1" - std::string before = cleanPattern.substr(0, pos); - before.erase(before.find_last_not_of(" \t") + 1); - if (!before.empty() && (before.back() == ')' || std::isalnum(static_cast(before.back())) || before.back() == '_')) { - quantOp = op; - quantThreshold = std::stoi(after); - hasQuantifier = true; - cleanPattern = before; - break; - } - } + // Parse and cache each pattern once before matching it against all + // species. Quantifier metadata remains per occurrence. + for (const auto& patternText : observable.getPatterns()) { + try { + std::string cleanPattern = patternText; + std::string quantifier; + int quantifierThreshold = 0; + bool hasQuantifier = false; + + static const std::vector ops = {">=", "<=", "==", "!=", ">", "<"}; + for (const auto& op : ops) { + const auto pos = cleanPattern.rfind(op); + if (pos == std::string::npos || pos == 0) { + continue; + } + + std::string after = cleanPattern.substr(pos + op.size()); + after.erase(0, after.find_first_not_of(" \t")); + after.erase(after.find_last_not_of(" \t") + 1); + bool isInteger = !after.empty(); + for (const char c : after) { + if (!std::isdigit(static_cast(c))) { + isInteger = false; + break; } } + if (!isInteger) { + continue; + } + + std::string before = cleanPattern.substr(0, pos); + before.erase(before.find_last_not_of(" \t") + 1); + if (before.empty() || + (before.back() != ')' && + !std::isalnum(static_cast(before.back())) && + before.back() != '_')) { + continue; + } + + quantifier = op; + quantifierThreshold = std::stoi(after); + hasQuantifier = true; + cleanPattern = std::move(before); + break; + } + auto cacheIt = parsedObservableCache.find(cleanPattern); + if (cacheIt == parsedObservableCache.end()) { antlr4::ANTLRInputStream input(cleanPattern); BNGLexer lexer(&input); antlr4::CommonTokenStream tokens(&lexer); BNGParser parser(&tokens); auto* species = parser.species_def(); - if (parser.getNumberOfSyntaxErrors() != 0) { throw std::runtime_error("Could not parse observable pattern: " + patternText); } - const auto pattern = bng::parser::buildPatternGraph(species, mutableModel, false); - - // Compartment matching strategy: - // 1. Molecule-level compartment (e.g., SARM()@Cyt) -- handled - // by Ullmann matcher's Node::operator== compartment check. - // 2. Species-level prefix compartment (e.g., @PM:L) -- no - // compartment on pattern nodes; filter at species level. - const auto patternCompartment = bng::parser::extractSpeciesCompartment(species); - if (!patternCompartment.empty()) { - bool patternHasMoleculeCompartment = false; - for (auto it = pattern.begin(); it != pattern.end(); ++it) { - if (!(*it)->get_compartment().empty()) { - patternHasMoleculeCompartment = true; - break; - } + auto graph = bng::parser::buildPatternGraph(species, mutableModel, false); + auto compartment = bng::parser::extractSpeciesCompartment(species); + cacheIt = parsedObservableCache.emplace( + cleanPattern, + CachedObservablePattern {std::move(graph), std::move(compartment)}) + .first; + } + + parsedPatterns.push_back({&cacheIt->second.graph, + cacheIt->second.compartment, + std::move(quantifier), + quantifierThreshold, + hasQuantifier}); + } catch (const std::exception& error) { + throw std::runtime_error("Failed to compile observable " + observable.getName() + + " pattern '" + patternText + "': " + error.what()); + } + } + + // Match each pattern against all species + for (std::size_t speciesIndex = 0; speciesIndex < network_.species.size(); ++speciesIndex) { + std::size_t weight = 0; + + for (const auto& parsedPattern : parsedPatterns) { + const auto& pattern = *parsedPattern.graph; + + // Compartment matching strategy: + // 1. Molecule-level compartment (e.g., SARM()@Cyt) -- handled + // by Ullmann matcher's Node::operator== compartment check. + // 2. Species-level prefix compartment (e.g., @PM:L) -- no + // compartment on pattern nodes; filter at species level. + const auto& patternCompartment = parsedPattern.compartment; + if (!patternCompartment.empty()) { + bool patternHasMoleculeCompartment = false; + for (auto it = pattern.begin(); it != pattern.end(); ++it) { + if (!(*it)->get_compartment().empty()) { + patternHasMoleculeCompartment = true; + break; } - if (!patternHasMoleculeCompartment) { - const auto& speciesCompartment = network_.species.get(speciesIndex).getCompartment(); - if (speciesCompartment != patternCompartment) { - continue; // Skip -- species-level compartment mismatch - } + } + if (!patternHasMoleculeCompartment) { + const auto& speciesCompartment = network_.species.get(speciesIndex).getCompartment(); + if (speciesCompartment != patternCompartment) { + continue; // Skip -- species-level compartment mismatch } } + } - // Count matches (with node-level state and structure validation) - const auto& targetGraph = network_.species.get(speciesIndex).getSpeciesGraph().getGraph(); - BNGcore::UllmannSGIso matcher(pattern, targetGraph); - BNGcore::List maps; - matcher.find_maps(maps); - - // Post-filter: verify each map respects node states, compartments, - // and structural roles (molecule vs component vs bond nodes). - // The Ullmann SGIso matches by type_name which may conflate molecule - // types and component types that share the same name (e.g., CDKN1A). - std::size_t matchCount = 0; - for (auto mapIt = maps.begin(); mapIt != maps.end(); ++mapIt) { - bool valid = true; - for (auto pnIt = pattern.begin(); pnIt != pattern.end(); ++pnIt) { - auto* target = mapIt->mapf(*pnIt); - if (!target) { valid = false; break; } - // Check state compatibility - if (!((*pnIt)->get_state() == target->get_state())) { valid = false; break; } - // Check structural role: molecule nodes must map to molecule nodes - bool patternIsMol = ((*pnIt)->in_degree() == 0); - bool targetIsMol = (target->in_degree() == 0); - if (patternIsMol != targetIsMol) { valid = false; break; } - // Check per-molecule compartment - if (patternIsMol) { - const auto& pc = (*pnIt)->get_compartment(); - if (!pc.empty() && target->get_compartment() != pc) { valid = false; break; } - } + // Count matches (with node-level state and structure validation) + const auto& targetGraph = network_.species.get(speciesIndex).getSpeciesGraph().getGraph(); + BNGcore::UllmannSGIso matcher(pattern, targetGraph); + BNGcore::List maps; + matcher.find_maps(maps); + + // Post-filter: verify each map respects node states, compartments, + // and structural roles (molecule vs component vs bond nodes). + // The Ullmann SGIso matches by type_name which may conflate molecule + // types and component types that share the same name (e.g., CDKN1A). + std::size_t matchCount = 0; + for (auto mapIt = maps.begin(); mapIt != maps.end(); ++mapIt) { + bool valid = true; + for (auto pnIt = pattern.begin(); pnIt != pattern.end(); ++pnIt) { + auto* target = mapIt->mapf(*pnIt); + if (!target) { valid = false; break; } + // Check state compatibility + if (!((*pnIt)->get_state() == target->get_state())) { valid = false; break; } + // Check structural role: molecule nodes must map to molecule nodes + bool patternIsMol = ((*pnIt)->in_degree() == 0); + bool targetIsMol = (target->in_degree() == 0); + if (patternIsMol != targetIsMol) { valid = false; break; } + // Check per-molecule compartment + if (patternIsMol) { + const auto& pc = (*pnIt)->get_compartment(); + if (!pc.empty() && target->get_compartment() != pc) { valid = false; break; } } - if (valid) ++matchCount; } + if (valid) ++matchCount; + } - // Apply quantifier filter if present - if (hasQuantifier) { - bool passes = false; - int mc = static_cast(matchCount); - if (quantOp == ">") passes = mc > quantThreshold; - else if (quantOp == "<") passes = mc < quantThreshold; - else if (quantOp == ">=") passes = mc >= quantThreshold; - else if (quantOp == "<=") passes = mc <= quantThreshold; - else if (quantOp == "==") passes = mc == quantThreshold; - else if (quantOp == "!=") passes = mc != quantThreshold; - matchCount = passes ? matchCount : 0; - } + // Apply quantifier filter if present + if (parsedPattern.hasQuantifier) { + bool passes = false; + const int matchCountInt = static_cast(matchCount); + if (parsedPattern.quantifier == ">") passes = matchCountInt > parsedPattern.quantifierThreshold; + else if (parsedPattern.quantifier == "<") passes = matchCountInt < parsedPattern.quantifierThreshold; + else if (parsedPattern.quantifier == ">=") passes = matchCountInt >= parsedPattern.quantifierThreshold; + else if (parsedPattern.quantifier == "<=") passes = matchCountInt <= parsedPattern.quantifierThreshold; + else if (parsedPattern.quantifier == "==") passes = matchCountInt == parsedPattern.quantifierThreshold; + else if (parsedPattern.quantifier == "!=") passes = matchCountInt != parsedPattern.quantifierThreshold; + matchCount = passes ? matchCount : 0; + } - // For "Species" observables, each pattern contributes 0 or 1 - // (presence/absence). The total weight is the number of - // patterns that match, NOT clamped to 1 across all patterns. - if (observable.getType() == "Species" && matchCount > 0) { - matchCount = 1; - } - weight += matchCount; - } catch (const std::exception& e) { - throw std::runtime_error("Failed to compile observable " + observable.getName() + - " pattern '" + patternText + "': " + e.what()); + // For "Species" observables, each pattern contributes 0 or 1 + // (presence/absence). The total weight is the number of + // patterns that match, NOT clamped to 1 across all patterns. + if (observable.getType() == "Species" && matchCount > 0) { + matchCount = 1; } + weight += matchCount; } if (weight > 0) { @@ -831,19 +957,43 @@ void OdeIntegrator::derivs(double t, const double* y, double* dydt) const { updateGroups(y, groupValues_); } - // Process constant-rate reactions first (no expression evaluation needed) - for (const auto idx : constantRxnIndices_) { - const auto& rxn = compiledRxns_[idx]; - double rate = rxn.rateConstant; + // Process constant-rate reactions first (no expression evaluation needed). + if (useCompactConstantReactions_) { + // Their small index arrays are flattened so this hot loop does not + // chase a vector allocation for every reaction. + for (const auto& rxn : constantReactions_) { + double rate = rxn.rateConstant; + + if (!rxn.isTotalRate && rxn.reactantCount > 0) { + const auto* reactants = constantReactantIndices_.data() + rxn.reactantOffset; + for (std::size_t i = 0; i < rxn.reactantCount; ++i) { + rate *= y[reactants[i]]; + } + } - if (!rxn.isTotalRate) { - for (const auto ri : rxn.reactantIndices) { - rate *= y[ri]; + if (rxn.reactantCount > 0) { + const auto* reactants = constantReactantIndices_.data() + rxn.reactantOffset; + for (std::size_t i = 0; i < rxn.reactantCount; ++i) { dydt[reactants[i]] -= rate; } + } + if (rxn.productCount > 0) { + const auto* products = constantProductIndices_.data() + rxn.productOffset; + for (std::size_t i = 0; i < rxn.productCount; ++i) { dydt[products[i]] += rate; } } } + } else { + for (const auto idx : constantRxnIndices_) { + const auto& rxn = compiledRxns_[idx]; + double rate = rxn.rateConstant; - for (const auto ri : rxn.reactantIndices) { dydt[ri] -= rate; } - for (const auto pi : rxn.productIndices) { dydt[pi] += rate; } + if (!rxn.isTotalRate) { + for (const auto ri : rxn.reactantIndices) { + rate *= y[ri]; + } + } + + for (const auto ri : rxn.reactantIndices) { dydt[ri] -= rate; } + for (const auto pi : rxn.productIndices) { dydt[pi] += rate; } + } } // Process functional-rate reactions (require expression evaluation) @@ -939,7 +1089,111 @@ void OdeIntegrator::updateGroups(const double* y, std::vector& groupValu } } +std::vector OdeIntegrator::outputTimes(const OdeOptions& options) const { + if (!std::isfinite(options.tStart) || !std::isfinite(options.tEnd)) { + throw std::runtime_error("t_start and t_end must be finite"); + } + if (options.tEnd < options.tStart) { + throw std::runtime_error("t_end must be greater than or equal to t_start"); + } + + if (!options.sampleTimes.empty()) { + std::vector times = options.sampleTimes; + for (std::size_t i = 0; i < times.size(); ++i) { + if (!std::isfinite(times[i])) { + throw std::runtime_error("sample_times must contain only finite values"); + } + if (times[i] < options.tStart || times[i] > options.tEnd) { + throw std::runtime_error( + "sample_times must lie between t_start and t_end"); + } + if (i > 0 && times[i] <= times[i - 1]) { + throw std::runtime_error( + "sample_times must be strictly increasing"); + } + } + return times; + } + + if (options.nSteps == 0) { + throw std::runtime_error("n_steps must be positive"); + } + + const double dt = (options.tEnd - options.tStart) / + static_cast(options.nSteps); + std::vector times; + times.reserve(options.nSteps + 1); + for (std::size_t step = 0; step <= options.nSteps; ++step) { + times.push_back(options.tStart + step * dt); + } + return times; +} + +std::optional OdeIntegrator::parseStopIf( + const OdeOptions& options) const { + if (options.stopIf.empty()) { + return std::nullopt; + } + try { + return parser::parseExpression(options.stopIf); + } catch (const std::exception& error) { + throw std::runtime_error( + "Failed to parse stop_if expression: " + std::string(error.what())); + } +} + +bool OdeIntegrator::stopConditionMet(const ast::Expression& condition, + double time, + const std::vector& state) const { + std::vector groupValues; + updateGroups(state.data(), groupValues); + + const auto resolver = [&](const std::string& name) -> double { + if (name == "time") { + return time; + } + const auto observable = observableIndex_.find(name); + if (observable != observableIndex_.end()) { + return groupValues[observable->second]; + } + return model_.getParameters().evaluate(name, time); + }; + + try { + return condition.evaluate(resolver, time) != 0.0; + } catch (const std::exception& error) { + throw std::runtime_error( + "Failed to evaluate stop_if expression '" + + condition.toString() + "': " + error.what()); + } +} + +bool OdeIntegrator::steadyStateReached(const OdeOptions& options, + double time, + const std::vector& state) const { + if (!options.steadyState) { + return false; + } + if (nSpecies_ == 0) { + return true; + } + + std::vector derivatives(nSpecies_); + derivs(time, state.data(), derivatives.data()); + double sumSq = 0.0; + for (const double derivative : derivatives) { + sumSq += derivative * derivative; + } + const double dx = std::sqrt(sumSq) / static_cast(nSpecies_); + return dx < options.steadyStateTol; +} + OdeResult OdeIntegrator::integrate(const OdeOptions& options) { + if (options.method != "ssa" && + (options.maxSimSteps > 0 || options.outputStepInterval > 0)) { + throw std::runtime_error( + "max_sim_steps and output_step_interval are supported only for SSA"); + } if (options.method == "euler") { return integrateEuler(options); } else if (options.method == "rk4") { @@ -954,88 +1208,48 @@ OdeResult OdeIntegrator::integrate(const OdeOptions& options) { } OdeResult OdeIntegrator::integrateEuler(const OdeOptions& opts) { - const double dt = (opts.tEnd - opts.tStart) / static_cast(opts.nSteps); + const auto times = outputTimes(opts); + const auto stopIfExpr = parseStopIf(opts); std::vector y(nSpecies_); - // Parse stop_if expression at compile time - std::optional stopIfExpr; - if (!opts.stopIf.empty()) { - try { - stopIfExpr = parser::parseExpression(opts.stopIf); - } catch (const std::exception& e) { - std::cerr << "[bng_cpp] Warning: failed to parse stop_if expression: " << e.what() << "\n"; - } - } - // Initialize from species amounts for (std::size_t i = 0; i < nSpecies_; ++i) { y[i] = network_.species.get(i).getAmount(); } OdeResult result; - result.timePoints.reserve(opts.nSteps + 1); - result.concentrations.reserve(opts.nSteps + 1); - - for (std::size_t step = 0; step <= opts.nSteps; ++step) { - double t = opts.tStart + step * dt; - result.timePoints.push_back(t); - result.concentrations.push_back(y); - - if (step < opts.nSteps) { + result.timePoints.reserve(times.size()); + result.concentrations.reserve(times.size()); + + double currentTime = opts.tStart; + for (std::size_t step = 0; step < times.size(); ++step) { + const double targetTime = times[step]; + const double dt = targetTime - currentTime; + if (dt > 0.0) { std::vector dydt(nSpecies_); - derivs(t, y.data(), dydt.data()); - - // Check steady-state condition (BNG2 parity) - if (opts.steadyState && step > 0) { - double sumSq = 0.0; - for (std::size_t i = 0; i < nSpecies_; ++i) { - sumSq += dydt[i] * dydt[i]; - } - double dx = std::sqrt(sumSq) / static_cast(nSpecies_); - if (dx < opts.steadyStateTol) { - std::cerr << "[bng_cpp] Steady state reached at step " << step - << ", t=" << t << ", dx=" << dx << "\n"; - break; // Stop early - } - } - - // Check stop_if condition (BNG2 parity) - if (stopIfExpr.has_value() && step > 0) { - std::vector groupValues; - updateGroups(y.data(), groupValues); - - auto resolver = [&](const std::string& name) -> double { - if (name == "time") return t; - for (std::size_t g = 0; g < compiledGroups_.size(); ++g) { - if (compiledGroups_[g].name == name) { - return groupValues[g]; - } - } - return model_.getParameters().evaluate(name); - }; - - try { - double stopVal = stopIfExpr->evaluate(resolver, t); - if (stopVal != 0.0) { - std::cerr << "[bng_cpp] stop_if condition met at step " << step - << ", t=" << t << ": " << opts.stopIf << "\n"; - break; - } - } catch (const std::exception& e) { - // Ignore evaluation errors in stop_if - } - } - + derivs(currentTime, y.data(), dydt.data()); for (std::size_t i = 0; i < nSpecies_; ++i) { y[i] += dt * dydt[i]; - if (y[i] < 0.0) y[i] = 0.0; // Clamp negative concentrations + if (y[i] < 0.0) y[i] = 0.0; } + currentTime = targetTime; + } + + result.timePoints.push_back(targetTime); + result.concentrations.push_back(y); + + if (step > 0 && steadyStateReached(opts, targetTime, y)) { + break; + } + if (step > 0 && stopIfExpr.has_value() && + stopConditionMet(*stopIfExpr, targetTime, y)) { + break; } } // Compute observables for each time point result.observables.resize(result.timePoints.size()); - for (std::size_t step = 0; step <= opts.nSteps; ++step) { + for (std::size_t step = 0; step < result.timePoints.size(); ++step) { updateGroups(result.concentrations[step].data(), result.observables[step]); } @@ -1046,96 +1260,30 @@ OdeResult OdeIntegrator::integrateRK4(const OdeOptions& opts) { // Use internal sub-stepping for stability // For stiff systems, we need many more steps than the output points const std::size_t internalStepsPerOutput = 1000; // Subdivide each output interval - const double outputDt = (opts.tEnd - opts.tStart) / static_cast(opts.nSteps); - const double dt = outputDt / static_cast(internalStepsPerOutput); + const auto times = outputTimes(opts); + const auto stopIfExpr = parseStopIf(opts); std::vector y(nSpecies_); - // Parse stop_if expression at compile time - std::optional stopIfExpr; - if (!opts.stopIf.empty()) { - try { - stopIfExpr = parser::parseExpression(opts.stopIf); - } catch (const std::exception& e) { - std::cerr << "[bng_cpp] Warning: failed to parse stop_if expression: " << e.what() << "\n"; - } - } - // Initialize from species amounts for (std::size_t i = 0; i < nSpecies_; ++i) { y[i] = network_.species.get(i).getAmount(); } OdeResult result; - result.timePoints.reserve(opts.nSteps + 1); - result.concentrations.reserve(opts.nSteps + 1); + result.timePoints.reserve(times.size()); + result.concentrations.reserve(times.size()); std::vector k1(nSpecies_), k2(nSpecies_), k3(nSpecies_), k4(nSpecies_); std::vector yTemp(nSpecies_); double t = opts.tStart; - bool stopEarly = false; - - for (std::size_t outputStep = 0; outputStep <= opts.nSteps; ++outputStep) { - double targetT = opts.tStart + outputStep * outputDt; - - // Save output at this time point - result.timePoints.push_back(targetT); - result.concentrations.push_back(y); - - // Check steady-state and stop_if at each output point - if (outputStep > 0 && outputStep < opts.nSteps) { - std::vector dydt(nSpecies_); - derivs(t, y.data(), dydt.data()); - - // Steady-state detection (BNG2 parity) - if (opts.steadyState) { - double sumSq = 0.0; - for (std::size_t i = 0; i < nSpecies_; ++i) { - sumSq += dydt[i] * dydt[i]; - } - double dx = std::sqrt(sumSq) / static_cast(nSpecies_); - if (dx < opts.steadyStateTol) { - std::cerr << "[bng_cpp] Steady state reached at step " << outputStep - << ", t=" << t << ", dx=" << dx << "\n"; - stopEarly = true; - } - } - - // stop_if condition (BNG2 parity) - if (stopIfExpr.has_value()) { - std::vector groupValues; - updateGroups(y.data(), groupValues); - - auto resolver = [&](const std::string& name) -> double { - if (name == "time") return t; - for (std::size_t g = 0; g < compiledGroups_.size(); ++g) { - if (compiledGroups_[g].name == name) { - return groupValues[g]; - } - } - return model_.getParameters().evaluate(name); - }; - - try { - double stopVal = stopIfExpr->evaluate(resolver, t); - if (stopVal != 0.0) { - std::cerr << "[bng_cpp] stop_if condition met at step " << outputStep - << ", t=" << t << ": " << opts.stopIf << "\n"; - stopEarly = true; - } - } catch (const std::exception& e) { - // Ignore evaluation errors - } - } - } - - if (stopEarly || outputStep >= opts.nSteps) { - break; - } - - if (outputStep < opts.nSteps) { - // Take internal steps to reach next output point + for (std::size_t outputStep = 0; outputStep < times.size(); ++outputStep) { + const double targetT = times[outputStep]; + const double outputDt = targetT - t; + if (outputDt > 0.0) { + const double dt = outputDt / static_cast(internalStepsPerOutput); + // Take internal steps to reach the next output point. for (std::size_t internalStep = 0; internalStep < internalStepsPerOutput; ++internalStep) { // k1 = f(t, y) derivs(t, y.data(), k1.data()); @@ -1172,12 +1320,24 @@ OdeResult OdeIntegrator::integrateRK4(const OdeOptions& opts) { t += dt; } + t = targetT; + } + + result.timePoints.push_back(targetT); + result.concentrations.push_back(y); + + if (outputStep > 0 && steadyStateReached(opts, targetT, y)) { + break; + } + if (outputStep > 0 && stopIfExpr.has_value() && + stopConditionMet(*stopIfExpr, targetT, y)) { + break; } } // Compute observables for each time point result.observables.resize(result.timePoints.size()); - for (std::size_t step = 0; step <= opts.nSteps; ++step) { + for (std::size_t step = 0; step < result.timePoints.size(); ++step) { updateGroups(result.concentrations[step].data(), result.observables[step]); } @@ -1260,14 +1420,25 @@ void OdeIntegrator::writeOutputFiles(const std::string& prefix, const OdeResult& for (std::size_t gi = 0; gi < compiledGroups_.size() && gi < result.observables[step].size(); ++gi) { obsMap[compiledGroups_[gi].name] = result.observables[step][gi]; } - // Also include parameters - auto resolver = [&](const std::string& name) -> double { + // Also resolve model-defined functions recursively. A function + // output may reference another zero-argument function (for + // example f_diff() = f_correct() - f_builtin()), so treating + // every identifier as a parameter loses the model-function + // namespace and raises "Unknown parameter". + std::function resolver; + resolver = [&](const std::string& name) -> double { auto obsIt = obsMap.find(name); if (obsIt != obsMap.end()) return obsIt->second; + for (const auto& function : model_.getFunctions()) { + if (function.getName() == name && function.getArgs().empty()) { + return function.getExpression().evaluate( + resolver, result.timePoints[step]); + } + } return model_.getParameters().evaluate(name); }; for (auto& fexpr : funcExprs) { - double val = fexpr.evaluate(resolver); + double val = fexpr.evaluate(resolver, result.timePoints[step]); gdat << " " << std::setw(18) << val; } } @@ -1313,15 +1484,8 @@ static int cvodeCallbackWrapper(sunrealtype t, N_Vector y, N_Vector ydot, void* } OdeResult OdeIntegrator::integrateCvode(const OdeOptions& opts) { - // Parse stop_if expression at compile time - std::optional stopIfExpr; - if (!opts.stopIf.empty()) { - try { - stopIfExpr = parser::parseExpression(opts.stopIf); - } catch (const std::exception& e) { - std::cerr << "[bng_cpp] Warning: failed to parse stop_if expression: " << e.what() << "\n"; - } - } + const auto times = outputTimes(opts); + const auto stopIfExpr = parseStopIf(opts); // SUNDIALS v7: Create context (required for all SUNDIALS objects) SUNContext sunctx = nullptr; @@ -1372,6 +1536,15 @@ OdeResult OdeIntegrator::integrateCvode(const OdeOptions& opts) { // Set max number of steps (BNG2 default: 2000, auto-increases if needed) CVodeSetMaxNumSteps(cvode_mem, 2000); + if (opts.maxStep > 0.0) { + flag = CVodeSetMaxStep(cvode_mem, opts.maxStep); + if (flag != CV_SUCCESS) { + CVodeFree(&cvode_mem); + N_VDestroy(y); + SUNContext_Free(&sunctx); + throw std::runtime_error("CVodeSetMaxStep failed"); + } + } // Note: Perl's run_network does NOT enable stability limit detection. // Disabled for exact parity with Perl CVODE stepping behavior. @@ -1431,14 +1604,37 @@ OdeResult OdeIntegrator::integrateCvode(const OdeOptions& opts) { // Integration loop OdeResult result; - result.timePoints.reserve(opts.nSteps + 1); - result.concentrations.reserve(opts.nSteps + 1); + result.timePoints.reserve(times.size()); + result.concentrations.reserve(times.size()); - const double dt = (opts.tEnd - opts.tStart) / static_cast(opts.nSteps); double t = opts.tStart; - for (std::size_t step = 0; step <= opts.nSteps; ++step) { - double tOut = opts.tStart + step * dt; + for (std::size_t step = 0; step < times.size(); ++step) { + const double tOut = times[step]; + + if (tOut > t) { + long int maxSteps = 2000; + while (true) { + flag = CVode(cvode_mem, tOut, y, &t, CV_NORMAL); + + if (flag == CV_SUCCESS || flag == CV_TSTOP_RETURN) { + break; + } else if (flag == CV_TOO_MUCH_WORK) { + // Auto-increase max steps (matches BNG2 behavior) + maxSteps *= 2; + CVodeSetMaxNumSteps(cvode_mem, maxSteps); + continue; + } else { + SUNLinSolFree(LS); + if (A) SUNMatDestroy(A); + CVodeFree(&cvode_mem); + N_VDestroy(y); + SUNContext_Free(&sunctx); + throw std::runtime_error("CVODE failed with flag " + std::to_string(flag)); + } + } + t = tOut; + } // Save current state result.timePoints.push_back(tOut); @@ -1461,55 +1657,12 @@ OdeResult OdeIntegrator::integrateCvode(const OdeOptions& opts) { } } - // Check stop_if condition after each output point (BNG2 parity) - if (stopIfExpr.has_value() && step > 0 && step < opts.nSteps) { - std::vector groupValues; - updateGroups(conc.data(), groupValues); - - auto resolver = [&](const std::string& name) -> double { - if (name == "time") return tOut; - for (std::size_t g = 0; g < compiledGroups_.size(); ++g) { - if (compiledGroups_[g].name == name) { - return groupValues[g]; - } - } - return model_.getParameters().evaluate(name); - }; - - try { - double stopVal = stopIfExpr->evaluate(resolver, tOut); - if (stopVal != 0.0) { - std::cerr << "[bng_cpp] stop_if condition met at step " << step - << ", t=" << tOut << ": " << opts.stopIf << "\n"; - break; - } - } catch (const std::exception& e) { - // Ignore evaluation errors - } + if (step > 0 && steadyStateReached(opts, tOut, conc)) { + break; } - - // Integrate to next output point - if (step < opts.nSteps) { - long int maxSteps = 2000; - while (true) { - flag = CVode(cvode_mem, tOut + dt, y, &t, CV_NORMAL); - - if (flag == CV_SUCCESS || flag == CV_TSTOP_RETURN) { - break; - } else if (flag == CV_TOO_MUCH_WORK) { - // Auto-increase max steps (matches BNG2 behavior) - maxSteps *= 2; - CVodeSetMaxNumSteps(cvode_mem, maxSteps); - continue; - } else { - SUNLinSolFree(LS); - if (A) SUNMatDestroy(A); - CVodeFree(&cvode_mem); - N_VDestroy(y); - SUNContext_Free(&sunctx); - throw std::runtime_error("CVODE failed with flag " + std::to_string(flag)); - } - } + if (step > 0 && stopIfExpr.has_value() && + stopConditionMet(*stopIfExpr, tOut, conc)) { + break; } } @@ -1571,6 +1724,9 @@ double OdeIntegrator::computePropensity(const CompiledReaction& rxn, const std:: OdeResult OdeIntegrator::integrateSSA(const OdeOptions& opts) { // Direct Gillespie algorithm (matches BNG2 implementation) + const auto times = outputTimes(opts); + const auto stopIfExpr = parseStopIf(opts); + const bool explicitTimes = !opts.sampleTimes.empty(); std::mt19937_64 rng; if (opts.seed > 0) { rng.seed(opts.seed); @@ -1587,13 +1743,43 @@ OdeResult OdeIntegrator::integrateSSA(const OdeOptions& opts) { } OdeResult result; - result.timePoints.reserve(opts.nSteps + 1); - result.concentrations.reserve(opts.nSteps + 1); + result.timePoints.reserve(times.size() + 1); + result.concentrations.reserve(times.size() + 1); double t = opts.tStart; - const double dt = (opts.tEnd - opts.tStart) / static_cast(opts.nSteps); - std::size_t nextOutputStep = 0; + std::size_t nextOutputTime = 0; std::size_t ssaStepCount = 0; // track internal SSA steps for output_step_interval + bool stoppedEarly = false; + + const auto appendScheduledBefore = [&](double until) { + while (nextOutputTime < times.size() && + times[nextOutputTime] < until - 1e-12) { + result.timePoints.push_back(times[nextOutputTime]); + result.concentrations.push_back(y); + ++nextOutputTime; + } + }; + + const auto appendScheduledThrough = [&](double until) { + while (nextOutputTime < times.size() && + times[nextOutputTime] <= until + 1e-12) { + result.timePoints.push_back(times[nextOutputTime]); + result.concentrations.push_back(y); + ++nextOutputTime; + } + }; + + const auto appendEventState = [&]() { + if (result.timePoints.empty() || + std::abs(result.timePoints.back() - t) > 1e-12) { + result.timePoints.push_back(t); + result.concentrations.push_back(y); + } else { + // An event may land exactly on an explicit output time. The + // sample at that instant represents the post-event state. + result.concentrations.back() = y; + } + }; // Compute initial propensities std::vector propensities(compiledRxns_.size()); @@ -1610,24 +1796,30 @@ OdeResult OdeIntegrator::integrateSSA(const OdeOptions& opts) { recomputePropensities(); // Record initial state - if (opts.outputStepInterval > 0) { + if (opts.outputStepInterval == 0 || explicitTimes) { + appendScheduledThrough(t); + } else { result.timePoints.push_back(t); result.concentrations.push_back(y); } // Main SSA loop while (t < opts.tEnd) { - // Record output points as we pass them (only when not using output_step_interval) - if (opts.outputStepInterval == 0) { - while (opts.tStart + nextOutputStep * dt <= t && nextOutputStep <= opts.nSteps) { - result.timePoints.push_back(opts.tStart + nextOutputStep * dt); - result.concentrations.push_back(y); - ++nextOutputStep; - } + // Record scheduled output points as events advance the trajectory. + if (opts.outputStepInterval == 0 || explicitTimes) { + appendScheduledThrough(t); } // Check if we've passed the end time - if (t >= opts.tEnd || totalPropensity <= 0.0) { + if (opts.maxSimSteps > 0 && ssaStepCount >= opts.maxSimSteps) { + stoppedEarly = true; + break; + } + if (t >= opts.tEnd) { + break; + } + if (totalPropensity <= 0.0) { + t = opts.tEnd; break; } @@ -1644,7 +1836,12 @@ OdeResult OdeIntegrator::integrateSSA(const OdeOptions& opts) { break; } - t += tau; + const double eventTime = t + tau; + if (opts.outputStepInterval == 0 || explicitTimes) { + // Scheduled samples before the event see the pre-event state. + appendScheduledBefore(eventTime); + } + t = eventTime; // Select next reaction double r2 = uniform(rng); @@ -1662,6 +1859,7 @@ OdeResult OdeIntegrator::integrateSSA(const OdeOptions& opts) { if (selectedRxn >= compiledRxns_.size()) { // Shouldn't happen unless roundoff error + stoppedEarly = true; break; } @@ -1679,24 +1877,37 @@ OdeResult OdeIntegrator::integrateSSA(const OdeOptions& opts) { // Optimization: could track which reactions are affected by this species change recomputePropensities(); + if (opts.outputStepInterval == 0 || explicitTimes) { + // Include an explicit sample that coincides with the event using + // the post-event state. + appendScheduledThrough(t); + } + // output_step_interval: record output every N internal SSA steps ++ssaStepCount; - if (opts.outputStepInterval > 0 && (ssaStepCount % opts.outputStepInterval) == 0) { + if (opts.outputStepInterval > 0 && !explicitTimes && + (ssaStepCount % opts.outputStepInterval) == 0) { result.timePoints.push_back(t); result.concentrations.push_back(y); } + + if (stopIfExpr.has_value() && + stopConditionMet(*stopIfExpr, t, y)) { + stoppedEarly = true; + break; + } } - if (opts.outputStepInterval > 0) { - // Record final state at end time - result.timePoints.push_back(t); - result.concentrations.push_back(y); + if (opts.outputStepInterval > 0 && !explicitTimes) { + // Record final state at the actual stopping time. + appendEventState(); } else { - // Record final state if we haven't already (uniform time-interval mode) - while (nextOutputStep <= opts.nSteps) { - result.timePoints.push_back(opts.tStart + nextOutputStep * dt); - result.concentrations.push_back(y); - ++nextOutputStep; + if (!stoppedEarly) { + t = opts.tEnd; + } + appendScheduledThrough(t); + if (stoppedEarly) { + appendEventState(); } } diff --git a/cpp/engine/OdeIntegrator.hpp b/cpp/engine/OdeIntegrator.hpp index e65288da..e92bd685 100644 --- a/cpp/engine/OdeIntegrator.hpp +++ b/cpp/engine/OdeIntegrator.hpp @@ -76,6 +76,15 @@ class OdeIntegrator { bool isTotalRate = false; // true if rate is total (not multiplied by reactant conc) }; + struct CompiledConstantReaction { + std::size_t reactantOffset = 0; + std::size_t productOffset = 0; + std::size_t reactantCount = 0; + std::size_t productCount = 0; + double rateConstant = 0.0; + bool isTotalRate = false; + }; + struct CompiledGroup { std::string name; std::vector> entries; // (speciesIndex, weight) @@ -90,13 +99,25 @@ class OdeIntegrator { // Performance optimizations mutable std::vector groupValues_; // Pre-allocated for derivs() std::unordered_map observableIndex_; // O(1) observable lookup - std::vector constantRxnIndices_; // Indices of constant-rate reactions + std::vector constantRxnIndices_; // Fallback indices for small networks + std::vector constantReactions_; // Compact constant-rate reaction data + std::vector constantReactantIndices_; // Flattened reactant species indices + std::vector constantProductIndices_; // Flattened product species indices + bool useCompactConstantReactions_ = false; std::vector functionalRxnIndices_; // Indices of functional-rate reactions io::TfunRegistry tfunRegistry_; // Time-function tables for TFUN expressions void compile(); void compileGroups(); void updateGroups(const double* y, std::vector& groupValues) const; + std::vector outputTimes(const OdeOptions& options) const; + std::optional parseStopIf(const OdeOptions& options) const; + bool stopConditionMet(const ast::Expression& condition, + double time, + const std::vector& state) const; + bool steadyStateReached(const OdeOptions& options, + double time, + const std::vector& state) const; OdeResult integrateEuler(const OdeOptions& opts); OdeResult integrateRK4(const OdeOptions& opts); diff --git a/cpp/engine/PlaSimulator.cpp b/cpp/engine/PlaSimulator.cpp index 79030c56..bed01510 100644 --- a/cpp/engine/PlaSimulator.cpp +++ b/cpp/engine/PlaSimulator.cpp @@ -7,6 +7,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include "antlr4-runtime.h" #include "BNGLexer.h" #include "BNGParser.h" diff --git a/cpp/engine/PsaSimulator.cpp b/cpp/engine/PsaSimulator.cpp index 1c5717b9..535e4072 100644 --- a/cpp/engine/PsaSimulator.cpp +++ b/cpp/engine/PsaSimulator.cpp @@ -7,6 +7,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include "antlr4-runtime.h" #include "BNGLexer.h" #include "BNGParser.h" diff --git a/cpp/io/BnglWriter.cpp b/cpp/io/BnglWriter.cpp index 68a86d1d..2930e709 100644 --- a/cpp/io/BnglWriter.cpp +++ b/cpp/io/BnglWriter.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include namespace bng::io { @@ -133,7 +135,18 @@ std::string BnglWriter::writeMoleculeTypes(const ast::Model& model) { std::ostringstream bngl; bngl << "begin molecule types\n"; + std::vector moleculeTypes; + moleculeTypes.reserve(model.getMoleculeTypes().size()); for (const auto& mt : model.getMoleculeTypes()) { + moleculeTypes.push_back(&mt); + } + std::sort(moleculeTypes.begin(), moleculeTypes.end(), + [](const auto* left, const auto* right) { + return left->getName() < right->getName(); + }); + + for (const auto* mtPtr : moleculeTypes) { + const auto& mt = *mtPtr; bngl << " " << mt.getName(); const auto& components = mt.getComponents(); diff --git a/cpp/io/CppExportWriter.cpp b/cpp/io/CppExportWriter.cpp index b42b31c1..9aed56a2 100644 --- a/cpp/io/CppExportWriter.cpp +++ b/cpp/io/CppExportWriter.cpp @@ -8,6 +8,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include #include "generated/BNGLexer.h" #include "generated/BNGParser.h" diff --git a/cpp/io/LatexWriter.cpp b/cpp/io/LatexWriter.cpp index e4537ef7..00ebdcf1 100644 --- a/cpp/io/LatexWriter.cpp +++ b/cpp/io/LatexWriter.cpp @@ -8,6 +8,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include #include "generated/BNGLexer.h" #include "generated/BNGParser.h" diff --git a/cpp/io/MatlabWriter.cpp b/cpp/io/MatlabWriter.cpp index c7797bcc..aeb91ade 100644 --- a/cpp/io/MatlabWriter.cpp +++ b/cpp/io/MatlabWriter.cpp @@ -8,6 +8,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include #include "generated/BNGLexer.h" #include "generated/BNGParser.h" diff --git a/cpp/io/MexWriter.cpp b/cpp/io/MexWriter.cpp index d4c669e4..b3a627d5 100644 --- a/cpp/io/MexWriter.cpp +++ b/cpp/io/MexWriter.cpp @@ -8,6 +8,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include #include "generated/BNGLexer.h" #include "generated/BNGParser.h" diff --git a/cpp/io/NetWriter.cpp b/cpp/io/NetWriter.cpp index f95dbd09..59ddeff4 100644 --- a/cpp/io/NetWriter.cpp +++ b/cpp/io/NetWriter.cpp @@ -13,6 +13,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include #include "core/List.hpp" @@ -994,7 +995,39 @@ std::unordered_map NetWriter::buildDerivedRatePara continue; } if (isModelFunction) { - // No-arg model function — write directly as function name + // No-arg model function — write directly as function name. + // A model-function forward rate can still be reversible; do + // not skip derivation of a complex reverse rate when taking + // this early path. + if (rule.isBidirectional() && rule.getRates().size() > 1) { + const auto& reverseRateExprObj = rule.getRates()[1]; + const std::string reverseRateExpr = reverseRateExprObj.toString(); + bool reverseIsModelFunction = false; + if (reverseRateExprObj.kind() == ast::ExpressionKind::Function || + reverseRateExprObj.kind() == ast::ExpressionKind::ObservableRef) { + for (const auto& function : model.getFunctions()) { + if (function.getName() == reverseRateExprObj.name()) { + reverseIsModelFunction = true; + break; + } + } + } + if (!reverseIsModelFunction && !isSimpleExpression(reverseRateExpr)) { + const bool reverseAsFunction = referencesObservables(reverseRateExpr); + const std::string reverseParamName = + (reverseAsFunction ? "_rateLaw" : "rateLaw") + + std::to_string(rateLawCounter++); + derived.emplace( + "_reverse__" + ruleName, + DerivedRateInfo { + reverseParamName, + reverseRateExpr, + reverseRateExprObj, + true, + reverseAsFunction + }); + } + } continue; } } @@ -1569,13 +1602,50 @@ void NetWriter::write(const std::filesystem::path& outputPath, ast::Model& model out << "begin groups\n"; std::size_t groupIndex = 1; + + struct CachedObservablePattern { + BNGcore::PatternGraph graph; + std::string compartment; + }; + struct ParsedPatternInfo { + const BNGcore::PatternGraph* graph; + std::string compartment; + bool hasMoleculeCompartment; + }; + std::unordered_map parsedObservableCache; + for (const auto& observable : model.getObservables()) { out << " " << groupIndex++ << " " << observable.getName() << " "; bool firstEntry = true; + + std::vector parsedPatterns; + parsedPatterns.reserve(observable.getPatterns().size()); + for (const auto& patternText : observable.getPatterns()) { + auto cacheIt = parsedObservableCache.find(patternText); + if (cacheIt == parsedObservableCache.end()) { + auto parsed = parseObservablePatternWithCompartment(patternText, model); + cacheIt = parsedObservableCache.emplace( + patternText, + CachedObservablePattern {std::move(parsed.first), std::move(parsed.second)}) + .first; + } + + const auto& pattern = cacheIt->second.graph; + bool hasMoleculeCompartment = false; + for (auto it = pattern.begin(); it != pattern.end(); ++it) { + if (!(*it)->get_compartment().empty()) { + hasMoleculeCompartment = true; + break; + } + } + parsedPatterns.push_back({&pattern, cacheIt->second.compartment, hasMoleculeCompartment}); + } + for (std::size_t speciesIndex = 0; speciesIndex < network.species.size(); ++speciesIndex) { std::size_t weight = 0; - for (const auto& patternText : observable.getPatterns()) { - auto [pattern, patternCompartment] = parseObservablePatternWithCompartment(patternText, model); + for (const auto& parsedPattern : parsedPatterns) { + const auto& pattern = *parsedPattern.graph; + const auto& patternCompartment = parsedPattern.compartment; // Compartment matching strategy: // 1. Molecule-level compartment (e.g., SARM()@Cyt) -- compartment is @@ -1584,14 +1654,7 @@ void NetWriter::write(const std::filesystem::path& outputPath, ast::Model& model // 2. Species-level prefix compartment (e.g., @PM:L) -- no compartment // on pattern molecule nodes. Need species-level filter. // Detect which case by checking if any pattern molecule node has a compartment. - bool patternHasMoleculeCompartment = false; - for (auto it = pattern.begin(); it != pattern.end(); ++it) { - if (!(*it)->get_compartment().empty()) { - patternHasMoleculeCompartment = true; - break; - } - } - if (!patternCompartment.empty() && !patternHasMoleculeCompartment) { + if (!patternCompartment.empty() && !parsedPattern.hasMoleculeCompartment) { // Species-level prefix compartment -- filter at species level const auto& speciesCompartment = network.species.get(speciesIndex).getCompartment(); if (speciesCompartment != patternCompartment) { diff --git a/cpp/io/PythonExportWriter.cpp b/cpp/io/PythonExportWriter.cpp index b3dcffcd..4f9bece6 100644 --- a/cpp/io/PythonExportWriter.cpp +++ b/cpp/io/PythonExportWriter.cpp @@ -8,6 +8,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include #include "generated/BNGLexer.h" #include "generated/BNGParser.h" diff --git a/cpp/io/RegulatoryGraphWriter.cpp b/cpp/io/RegulatoryGraphWriter.cpp index 480c5eba..8cd23ca7 100644 --- a/cpp/io/RegulatoryGraphWriter.cpp +++ b/cpp/io/RegulatoryGraphWriter.cpp @@ -5,6 +5,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include #include "generated/BNGLexer.h" #include "generated/BNGParser.h" diff --git a/cpp/io/SbmlReader.cpp b/cpp/io/SbmlReader.cpp new file mode 100644 index 00000000..a653c89e --- /dev/null +++ b/cpp/io/SbmlReader.cpp @@ -0,0 +1,457 @@ +#include "SbmlReader.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "nfsim/NFinput/TinyXML/tinyxml.h" + +namespace bng::io { + +namespace { + +std::string trim(const std::string& value) { + auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char c) { + return std::isspace(c) != 0; + }); + auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char c) { + return std::isspace(c) != 0; + }).base(); + if (first >= last) { + return {}; + } + return std::string(first, last); +} + +std::string attribute(const TiXmlElement* element, const char* name, + const std::string& fallback = {}) { + if (element == nullptr || element->Attribute(name) == nullptr) { + return fallback; + } + return element->Attribute(name); +} + +std::string elementText(const TiXmlElement* element) { + return element == nullptr || element->GetText() == nullptr + ? std::string{} + : trim(element->GetText()); +} + +std::string sanitizeName(std::string name) { + std::string result; + for (const unsigned char c : name) { + if (c == '(' || c == ')') { + // Historical BNG2 flat-SBML conversion reserves two underscores + // for each pattern delimiter: A() -> A____. + result += "__"; + } else if (std::isalnum(c) != 0 || c == '_') { + result += static_cast(c); + } else if (c == '-' || std::isspace(c) != 0) { + result += '_'; + } + } + if (result.empty()) { + result = "species"; + } + if (std::isdigit(static_cast(result.front())) != 0) { + result.insert(0, "s"); + } + return result; +} + +bool parseBool(const std::string& value) { + std::string lower; + lower.reserve(value.size()); + for (const unsigned char c : value) { + lower += static_cast(std::tolower(c)); + } + return lower == "1" || lower == "true" || lower == "yes"; +} + +std::string mathExpression(const TiXmlElement* element); + +std::vector childElements(const TiXmlElement* element) { + std::vector children; + if (element == nullptr) { + return children; + } + for (auto* child = element->FirstChildElement(); child != nullptr; + child = child->NextSiblingElement()) { + children.push_back(child); + } + return children; +} + +std::string join(const std::vector& values, const std::string& op) { + std::ostringstream result; + for (std::size_t i = 0; i < values.size(); ++i) { + if (i != 0) { + result << op; + } + result << values[i]; + } + return result.str(); +} + +std::string mathExpression(const TiXmlElement* element) { + if (element == nullptr) { + return {}; + } + const std::string tag = element->Value(); + if (tag == "math") { + return mathExpression(element->FirstChildElement()); + } + if (tag == "ci" || tag == "cn") { + return elementText(element); + } + if (tag != "apply") { + return {}; + } + + const auto children = childElements(element); + if (children.empty()) { + return {}; + } + const std::string op = children.front()->Value(); + std::vector args; + for (std::size_t i = 1; i < children.size(); ++i) { + const auto value = mathExpression(children[i]); + if (!value.empty()) { + args.push_back(value); + } + } + if (op == "plus") { + return join(args, "+"); + } + if (op == "times") { + return join(args, "*"); + } + if (op == "divide" && args.size() == 2) { + return args[0] + "/" + args[1]; + } + if (op == "power" && args.size() == 2) { + return args[0] + "^" + args[1]; + } + if (op == "minus") { + return args.size() == 1 ? "-" + args[0] : join(args, "-"); + } + if (args.size() == 1 && + (op == "abs" || op == "exp" || op == "ln" || op == "log" || + op == "sqrt" || op == "sin" || op == "cos" || op == "tan")) { + return op + "(" + args.front() + ")"; + } + throw std::runtime_error("unsupported MathML operator: " + op); +} + +std::vector splitTopLevel(const std::string& expression, char delimiter) { + std::vector result; + std::size_t start = 0; + int depth = 0; + for (std::size_t i = 0; i < expression.size(); ++i) { + if (expression[i] == '(') { + ++depth; + } else if (expression[i] == ')') { + --depth; + } else if (expression[i] == delimiter && depth == 0) { + result.push_back(trim(expression.substr(start, i - start))); + start = i + 1; + } + } + result.push_back(trim(expression.substr(start))); + return result; +} + +std::string stripReactantFactors( + const std::string& expression, const std::vector& reactants) { + auto factors = splitTopLevel(expression, '*'); + std::map required; + for (const auto& reactant : reactants) { + ++required[reactant]; + } + for (auto& factor : factors) { + auto candidate = factor; + if (candidate.size() >= 2 && candidate.front() == '(' && + candidate.back() == ')') { + candidate = candidate.substr(1, candidate.size() - 2); + } + const auto found = required.find(candidate); + if (found != required.end() && found->second > 0) { + factor.clear(); + --found->second; + } + } + for (const auto& [reactant, count] : required) { + if (count != 0) { + throw std::runtime_error( + "kinetic law does not expose mass-action factor for " + reactant); + } + } + std::vector remaining; + for (const auto& factor : factors) { + if (!factor.empty()) { + remaining.push_back(factor); + } + } + return remaining.empty() ? "1" : join(remaining, "*"); +} + +void collectIdentifiers(const TiXmlElement* element, std::vector& ids) { + if (element == nullptr) { + return; + } + if (std::string(element->Value()) == "ci") { + const auto value = elementText(element); + if (!value.empty()) { + ids.push_back(value); + } + } + for (auto* child = element->FirstChildElement(); child != nullptr; + child = child->NextSiblingElement()) { + collectIdentifiers(child, ids); + } +} + +std::string indexList(const std::vector& indices) { + if (indices.empty()) { + return "0"; + } + std::ostringstream result; + for (std::size_t i = 0; i < indices.size(); ++i) { + if (i != 0) { + result << ','; + } + result << indices[i]; + } + return result.str(); +} + +std::string weightedList(const std::map& entries) { + std::ostringstream result; + bool first = true; + for (const auto& [index, weight] : entries) { + if (!first) { + result << ','; + } + first = false; + if (weight != 1) { + result << weight << '*'; + } + result << index; + } + return result.str(); +} + +} // namespace + +NetReader::ParseResult SbmlReader::parse( + const std::filesystem::path& filepath, bool atomize) { + NetReader::ParseResult result; + if (atomize) { + result.error = + "SBML atomize=true requires the Python atomizer conversion path"; + return result; + } + + const auto filename = filepath.string(); + TiXmlDocument document(filename.c_str()); + if (!document.LoadFile()) { + result.error = "Failed to parse SBML: " + std::string(document.ErrorDesc()); + return result; + } + const auto* root = document.RootElement(); + const auto* model = root == nullptr ? nullptr : root->FirstChildElement("model"); + if (root == nullptr || std::string(root->Value()) != "sbml" || model == nullptr) { + result.error = "SBML document has no model element"; + return result; + } + + try { + std::unordered_map speciesIndices; + std::set usedNames; + + const auto* compartments = model->FirstChildElement("listOfCompartments"); + if (compartments != nullptr) { + for (auto* compartment = compartments->FirstChildElement("compartment"); + compartment != nullptr; + compartment = compartment->NextSiblingElement("compartment")) { + result.compartments.push_back(attribute(compartment, "id")); + } + } + + const auto* speciesList = model->FirstChildElement("listOfSpecies"); + if (speciesList != nullptr) { + int index = 0; + for (auto* species = speciesList->FirstChildElement("species"); + species != nullptr; + species = species->NextSiblingElement("species")) { + ++index; + const auto id = attribute(species, "id"); + auto standardized = sanitizeName(attribute(species, "name", id)); + if (!usedNames.insert(standardized).second) { + standardized += "_" + id; + usedNames.insert(standardized); + } + const auto compartment = attribute(species, "compartment"); + std::string pattern; + if (!compartment.empty()) { + pattern = "@" + compartment + "::"; + } + if (parseBool(attribute(species, "constant")) || + parseBool(attribute(species, "boundaryCondition"))) { + pattern += '$'; + } + pattern += standardized + "()"; + speciesIndices[id] = index; + std::string amount = attribute(species, "initialAmount"); + if (amount.empty()) { + amount = attribute(species, "initialConcentration", "0"); + } + result.species.emplace_back(pattern, amount); + } + } + if (result.species.empty()) { + throw std::runtime_error("SBML model has no species"); + } + + const auto* parameterList = model->FirstChildElement("listOfParameters"); + if (parameterList != nullptr) { + for (auto* parameter = parameterList->FirstChildElement("parameter"); + parameter != nullptr; + parameter = parameter->NextSiblingElement("parameter")) { + const auto id = attribute(parameter, "id"); + const auto value = attribute(parameter, "value"); + if (!id.empty() && !value.empty()) { + result.parameters[id] = std::stod(value); + } + } + } + + const auto* reactionList = model->FirstChildElement("listOfReactions"); + if (reactionList != nullptr) { + int index = 0; + for (auto* reaction = reactionList->FirstChildElement("reaction"); + reaction != nullptr; + reaction = reaction->NextSiblingElement("reaction")) { + ++index; + std::vector reactantIndices; + std::vector reactantIds; + const auto* reactants = reaction->FirstChildElement("listOfReactants"); + if (reactants != nullptr) { + for (auto* reference = reactants->FirstChildElement("speciesReference"); + reference != nullptr; + reference = reference->NextSiblingElement("speciesReference")) { + const auto id = attribute(reference, "species"); + const auto found = speciesIndices.find(id); + if (found == speciesIndices.end()) { + throw std::runtime_error( + "reaction references unknown species " + id); + } + int stoich = 1; + const auto stoichText = attribute(reference, "stoichiometry"); + if (!stoichText.empty()) { + stoich = std::max( + 1, static_cast(std::stod(stoichText))); + } + for (int copy = 0; copy < stoich; ++copy) { + reactantIndices.push_back(found->second); + reactantIds.push_back(id); + } + } + } + + std::vector productIndices; + const auto* products = reaction->FirstChildElement("listOfProducts"); + if (products != nullptr) { + for (auto* reference = products->FirstChildElement("speciesReference"); + reference != nullptr; + reference = reference->NextSiblingElement("speciesReference")) { + const auto id = attribute(reference, "species"); + const auto found = speciesIndices.find(id); + if (found == speciesIndices.end()) { + throw std::runtime_error( + "reaction references unknown species " + id); + } + int stoich = 1; + const auto stoichText = attribute(reference, "stoichiometry"); + if (!stoichText.empty()) { + stoich = std::max( + 1, static_cast(std::stod(stoichText))); + } + for (int copy = 0; copy < stoich; ++copy) { + productIndices.push_back(found->second); + } + } + } + + const auto* kineticLaw = reaction->FirstChildElement("kineticLaw"); + const auto* math = kineticLaw == nullptr + ? nullptr : kineticLaw->FirstChildElement("math"); + if (math == nullptr) { + throw std::runtime_error("reaction has no kinetic law"); + } + const auto expression = stripReactantFactors( + mathExpression(math), reactantIds); + std::ostringstream line; + line << index << ' ' << indexList(reactantIndices) << ' ' + << indexList(productIndices) << ' ' << expression; + const auto id = attribute(reaction, "id"); + if (!id.empty()) { + line << " #" << id; + } + result.reactions.push_back(line.str()); + } + } + + const auto* rules = model->FirstChildElement("listOfRules"); + if (rules != nullptr) { + int groupIndex = 0; + int functionIndex = 1; + for (auto* rule = rules->FirstChildElement("assignmentRule"); + rule != nullptr; + rule = rule->NextSiblingElement("assignmentRule")) { + std::vector identifiers; + collectIdentifiers(rule->FirstChildElement("math"), identifiers); + std::map entries; + for (const auto& identifier : identifiers) { + const auto found = speciesIndices.find(identifier); + if (found != speciesIndices.end()) { + ++entries[found->second]; + } + } + if (entries.empty()) { + const auto variable = attribute(rule, "variable"); + const auto expression = mathExpression( + rule->FirstChildElement("math")); + if (!variable.empty() && !expression.empty()) { + result.functions.emplace_back(variable, expression); + std::ostringstream line; + line << functionIndex++ << ' ' << variable << "() " + << expression; + result.rawFunctionLines.push_back(line.str()); + } + continue; + } + ++groupIndex; + const auto variable = attribute(rule, "variable", "group"); + std::ostringstream line; + line << groupIndex << ' ' << sanitizeName(variable) << ' ' + << weightedList(entries); + result.rawGroupLines.push_back(line.str()); + } + } + + result.success = true; + } catch (const std::exception& error) { + result.error = error.what(); + } + return result; +} + +} // namespace bng::io diff --git a/cpp/io/SbmlReader.hpp b/cpp/io/SbmlReader.hpp new file mode 100644 index 00000000..ed125125 --- /dev/null +++ b/cpp/io/SbmlReader.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +#include "io/NetReader.hpp" + +namespace bng::io { + +/** + * Read the flattened SBML dialect emitted by BioNetGen. + * + * This reader intentionally handles network SBML, not SBML Multi atomization. + * Multi/atomized conversion remains owned by the Python atomizer path. + */ +class SbmlReader { +public: + static NetReader::ParseResult parse( + const std::filesystem::path& filepath, bool atomize = false); +}; + +} // namespace bng::io diff --git a/cpp/io/SbmlWriter.cpp b/cpp/io/SbmlWriter.cpp index 3e782e28..7af83898 100644 --- a/cpp/io/SbmlWriter.cpp +++ b/cpp/io/SbmlWriter.cpp @@ -7,6 +7,7 @@ #include #include +#include "parser/antlr_compat.hpp" #include #include "generated/BNGLexer.h" #include "generated/BNGParser.h" diff --git a/cpp/io/XmlWriter.cpp b/cpp/io/XmlWriter.cpp index 6dfebb4d..5ca86c72 100644 --- a/cpp/io/XmlWriter.cpp +++ b/cpp/io/XmlWriter.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -71,6 +72,11 @@ struct ParsedReactionFilter { std::vector patterns; }; +bool isReactantCountReference(const std::string& name) { + return name.size() == 10 && name.compare(0, 9, "reactant_") == 0 && + name.back() >= '1' && name.back() <= '9'; +} + std::string trimText(std::string value) { const auto first = value.find_first_not_of(" \t\r\n"); if (first == std::string::npos) return {}; @@ -409,6 +415,197 @@ ParsedPattern parsePattern(const std::string& text) { return pattern; } +bool parsedComponentLess(const ParsedComponent& left, + const ParsedComponent& right) { + if (left.name != right.name) return left.name < right.name; + + const bool leftHasState = !left.state.empty(); + const bool rightHasState = !right.state.empty(); + if (leftHasState != rightHasState) return !leftHasState; + if (leftHasState && left.state != right.state) { + return left.state < right.state; + } + + // BNG2's cmp_component puts components with more edges first after + // comparing name and state. Labels are deliberately not compared: they + // are attributes, not part of the quasi-canonical ordering contract. + if (left.bonds.size() != right.bonds.size()) { + return left.bonds.size() > right.bonds.size(); + } + return false; +} + +bool parsedMoleculeLess(const ParsedMolecule& left, + const ParsedMolecule& right) { + if (left.name != right.name) return left.name < right.name; + if (left.components.size() != right.components.size()) { + return left.components.size() < right.components.size(); + } + + // BNG2 puts an explicitly compartmented molecule after an unassigned one, + // then compares compartment names lexically. + const bool leftHasCompartment = !left.compartment.empty(); + const bool rightHasCompartment = !right.compartment.empty(); + if (leftHasCompartment != rightHasCompartment) return !leftHasCompartment; + if (leftHasCompartment && left.compartment != right.compartment) { + return left.compartment < right.compartment; + } + + for (std::size_t index = 0; index < left.components.size(); ++index) { + const auto& leftComponent = left.components[index]; + const auto& rightComponent = right.components[index]; + if (parsedComponentLess(leftComponent, rightComponent)) return true; + if (parsedComponentLess(rightComponent, leftComponent)) return false; + } + return false; +} + +void canonicalizeParsedPattern(ParsedPattern& pattern) { + if (pattern.molecules.empty()) return; + + // Keep old component indices long enough to remap ParsedBond endpoints + // after BNG2's component and molecule sorts. + auto sourceMolecules = std::move(pattern.molecules); + std::vector> componentOrder(sourceMolecules.size()); + std::vector> componentMap(sourceMolecules.size()); + for (std::size_t moleculeIndex = 0; + moleculeIndex < sourceMolecules.size(); ++moleculeIndex) { + auto& order = componentOrder[moleculeIndex]; + order.resize(sourceMolecules[moleculeIndex].components.size()); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), [&](int left, int right) { + const auto& leftComponent = sourceMolecules[moleculeIndex].components[left]; + const auto& rightComponent = sourceMolecules[moleculeIndex].components[right]; + if (parsedComponentLess(leftComponent, rightComponent)) return true; + if (parsedComponentLess(rightComponent, leftComponent)) return false; + return left < right; + }); + + auto& map = componentMap[moleculeIndex]; + map.resize(order.size()); + std::vector sortedComponents; + sortedComponents.reserve(order.size()); + for (std::size_t newIndex = 0; newIndex < order.size(); ++newIndex) { + map[order[newIndex]] = static_cast(newIndex); + sortedComponents.push_back( + std::move(sourceMolecules[moleculeIndex].components[order[newIndex]])); + } + sourceMolecules[moleculeIndex].components = std::move(sortedComponents); + } + + std::vector moleculeOrder(sourceMolecules.size()); + std::iota(moleculeOrder.begin(), moleculeOrder.end(), 0); + std::stable_sort(moleculeOrder.begin(), moleculeOrder.end(), + [&](int left, int right) { + if (parsedMoleculeLess(sourceMolecules[left], sourceMolecules[right])) return true; + if (parsedMoleculeLess(sourceMolecules[right], sourceMolecules[left])) return false; + return left < right; + }); + + std::vector moleculeMap(sourceMolecules.size()); + pattern.molecules.reserve(sourceMolecules.size()); + for (std::size_t newIndex = 0; newIndex < moleculeOrder.size(); ++newIndex) { + const auto oldIndex = moleculeOrder[newIndex]; + moleculeMap[oldIndex] = static_cast(newIndex); + pattern.molecules.push_back(std::move(sourceMolecules[oldIndex])); + } + + std::vector remappedBonds; + remappedBonds.reserve(pattern.bonds.size()); + for (const auto& bond : pattern.bonds) { + if (bond.mol1 < 0 || bond.mol2 < 0 || + static_cast(bond.mol1) >= moleculeMap.size() || + static_cast(bond.mol2) >= moleculeMap.size() || + bond.comp1 < 0 || bond.comp2 < 0 || + static_cast(bond.comp1) >= componentMap[bond.mol1].size() || + static_cast(bond.comp2) >= componentMap[bond.mol2].size()) { + continue; + } + + ParsedBond remapped = bond; + remapped.mol1 = moleculeMap[bond.mol1]; + remapped.comp1 = componentMap[bond.mol1][bond.comp1]; + remapped.mol2 = moleculeMap[bond.mol2]; + remapped.comp2 = componentMap[bond.mol2][bond.comp2]; + if (remapped.mol2 < remapped.mol1 || + (remapped.mol2 == remapped.mol1 && remapped.comp2 < remapped.comp1)) { + std::swap(remapped.mol1, remapped.mol2); + std::swap(remapped.comp1, remapped.comp2); + } + remappedBonds.push_back(remapped); + } + std::sort(remappedBonds.begin(), remappedBonds.end(), [](const ParsedBond& left, + const ParsedBond& right) { + if (left.mol1 != right.mol1) return left.mol1 < right.mol1; + if (left.comp1 != right.comp1) return left.comp1 < right.comp1; + if (left.mol2 != right.mol2) return left.mol2 < right.mol2; + if (left.comp2 != right.comp2) return left.comp2 < right.comp2; + return left.id < right.id; + }); + + // BNG2 rebuilds complete edge IDs in endpoint order. Map any dangling + // numeric edge labels after complete bonds so their XML bond counts remain + // visible without inventing a second endpoint. + std::map canonicalBondIds; + int nextBondId = 1; + for (const auto& bond : remappedBonds) { + canonicalBondIds.emplace(bond.id, nextBondId++); + } + for (auto& molecule : pattern.molecules) { + for (auto& component : molecule.components) { + for (auto& bondId : component.bonds) { + if (bondId < 0) continue; + auto [it, inserted] = canonicalBondIds.emplace(bondId, nextBondId); + if (inserted) ++nextBondId; + bondId = it->second; + } + } + } + + for (auto& bond : remappedBonds) { + bond.id = canonicalBondIds.at(bond.id); + } + pattern.bonds = std::move(remappedBonds); +} + +std::string patternToBngl(const ParsedPattern& pattern, bool constant) { + std::ostringstream result; + if (!pattern.compartment.empty()) { + result << "@" << pattern.compartment << "::"; + } + if (constant) result << "$"; + + for (std::size_t moleculeIndex = 0; + moleculeIndex < pattern.molecules.size(); ++moleculeIndex) { + if (moleculeIndex != 0) result << "."; + const auto& molecule = pattern.molecules[moleculeIndex]; + result << molecule.name; + if (!molecule.label.empty()) result << "%" << molecule.label; + if (!molecule.components.empty()) { + result << "("; + for (std::size_t componentIndex = 0; + componentIndex < molecule.components.size(); ++componentIndex) { + if (componentIndex != 0) result << ","; + const auto& component = molecule.components[componentIndex]; + result << component.name; + if (!component.state.empty()) result << "~" << component.state; + if (!component.label.empty()) result << "%" << component.label; + for (const int bondId : component.bonds) { + if (bondId == -1) result << "!?"; + else if (bondId == -2) result << "!+"; + else result << "!" << bondId; + } + } + result << ")"; + } + if (!molecule.compartment.empty() && + molecule.compartment != pattern.compartment) { + result << "@" << molecule.compartment; + } + } + return result.str(); +} + std::string patternToXml(const ParsedPattern& pattern, const std::string& idPrefix, const std::string& indent) { std::ostringstream xml; @@ -483,15 +680,82 @@ bool modelHasFunction(const ast::Model& model, const std::string& name) { [&](const auto& function) { return function.getName() == name; }); } +const ast::Function* findModelFunction(const ast::Model& model, + const std::string& name) { + const auto found = std::find_if( + model.getFunctions().begin(), model.getFunctions().end(), + [&](const auto& function) { return function.getName() == name; }); + return found == model.getFunctions().end() ? nullptr : &*found; +} + +bool isBoundedLocalFunctionCall(const ast::Model& model, + const ast::Expression& expression) { + using ast::ExpressionKind; + if (expression.kind() != ExpressionKind::Function && + expression.kind() != ExpressionKind::ObservableRef) { + return false; + } + const auto* function = findModelFunction(model, expression.name()); + return function != nullptr && function->getArgs().size() == 1 && + expression.args().size() == 1 && + expression.args().front().kind() == ExpressionKind::Identifier; +} + +bool isBoundedRawLocalFunctionProduct(const ast::Model& model, + const ast::Expression& expression) { + return expression.kind() == ast::ExpressionKind::Binary && + expression.name() == "*" && expression.args().size() == 2 && + isBoundedLocalFunctionCall(model, expression.args()[0]) && + isBoundedLocalFunctionCall(model, expression.args()[1]); +} + struct GeneratedRateFunction { std::string name; const ast::Expression* expression = nullptr; + std::vector arguments; std::string expandedExpression; std::map references; const ast::Expression* tableFunction = nullptr; std::map observableAliases; }; +bool collectDynamicRateArguments(const ast::Expression& expression, + const ast::Model& model, + std::vector& arguments, + std::set& seen, + std::string& diagnostic) { + using ast::ExpressionKind; + + if (expression.kind() == ExpressionKind::Function || + expression.kind() == ExpressionKind::ObservableRef) { + const auto* function = findModelFunction(model, expression.name()); + if (function != nullptr && !expression.args().empty()) { + if (function->getArgs().size() != expression.args().size()) { + diagnostic = "dynamic reaction rate function '" + expression.name() + + "' received the wrong number of arguments"; + return false; + } + for (const auto& argument : expression.args()) { + if (argument.kind() != ExpressionKind::Identifier) { + diagnostic = "dynamic reaction rate local function arguments must be identifiers"; + return false; + } + if (seen.insert(argument.name()).second) { + arguments.push_back(argument.name()); + } + } + } + } + + for (const auto& child : expression.args()) { + if (!collectDynamicRateArguments( + child, model, arguments, seen, diagnostic)) { + return false; + } + } + return true; +} + std::string lowercaseName(std::string value); std::string replaceNamedReferences( @@ -575,6 +839,8 @@ void collectFunctionReferences(const ast::Expression& expression, const auto& name = expression.name(); if (localNames.count(name) != 0) { addFunctionReference(references, name, "Local"); + } else if (isReactantCountReference(name)) { + addFunctionReference(references, name, "Function"); } else if (name == "time" || name == "t") { addFunctionReference(references, name, "Time"); } else if (model.getParameters().contains(name)) { @@ -593,6 +859,9 @@ void collectFunctionReferences(const ast::Expression& expression, if ((expression.name() == "time" || expression.name() == "t") && expression.args().empty()) { addFunctionReference(references, expression.name(), "Time"); + } else if (isReactantCountReference(expression.name()) && + expression.args().empty()) { + addFunctionReference(references, expression.name(), "Function"); } else if (modelHasFunction(model, expression.name())) { addFunctionReference(references, expression.name(), "Function"); } else if (expression.args().empty() && @@ -607,7 +876,9 @@ void collectFunctionReferences(const ast::Expression& expression, } return; case ExpressionKind::ObservableRef: - if (modelHasFunction(model, expression.name())) { + if (isReactantCountReference(expression.name()) && expression.args().empty()) { + addFunctionReference(references, expression.name(), "Function"); + } else if (modelHasFunction(model, expression.name())) { addFunctionReference(references, expression.name(), "Function"); } else { addFunctionReference(references, expression.name(), "Observable"); @@ -806,6 +1077,15 @@ bool expandDynamicRateExpression( expanded = lowerName; return true; } + if (isReactantCountReference(name)) { + if (!expression.args().empty()) { + diagnostic = name + "() takes no arguments"; + return false; + } + addFunctionReference(references, name, "Function"); + expanded = name + "()"; + return true; + } if (const auto* function = findFunction(name)) { if (!expression.args().empty()) { diagnostic = "dynamic rates cannot call zero-argument model function '" + @@ -843,6 +1123,15 @@ bool expandDynamicRateExpression( } case ExpressionKind::ObservableRef: { const auto& name = expression.name(); + if (isReactantCountReference(name)) { + if (!expression.args().empty()) { + diagnostic = name + "() takes no arguments"; + return false; + } + addFunctionReference(references, name, "Function"); + expanded = name + "()"; + return true; + } if (const auto* function = findFunction(name)) { if (!expression.args().empty()) { diagnostic = "dynamic rates cannot call zero-argument model function '" + @@ -977,11 +1266,39 @@ bool modelContainsFunction(const ast::Model& model, const std::string& name) { [&](const auto& function) { return function.getName() == name; }); } +bool expressionContainsModelFunctionReference(const ast::Expression& expression, + const ast::Model& model) { + const bool namedExpression = + expression.kind() == ast::ExpressionKind::Identifier || + expression.kind() == ast::ExpressionKind::Function || + expression.kind() == ast::ExpressionKind::ObservableRef; + if (namedExpression && modelContainsFunction(model, expression.name())) return true; + return std::any_of( + expression.args().begin(), expression.args().end(), + [&](const auto& child) { + return expressionContainsModelFunctionReference(child, model); + }); +} + bool needsGeneratedDynamicRateFunction(const ast::Model& model, const ast::Expression& rate) { + if (isBoundedRawLocalFunctionProduct(model, rate)) return false; const bool isCall = rate.kind() == ast::ExpressionKind::Function || rate.kind() == ast::ExpressionKind::ObservableRef; - if (isCall && modelContainsFunction(model, rate.name())) return false; + if (isCall && modelContainsFunction(model, rate.name())) { + // NFsim's XML loader represents a plain scoped LocalFunction rate + // through a composite wrapper (see nfsim/test/testSuite/t3.xml). + // Keep the direct AST path on the declared local function, but + // generate the compatibility wrapper for serialized XML when the + // function takes local scope arguments. Nested model functions are + // already represented as CompositeFunctions and cannot be referenced + // from another composite wrapper, so retain their direct name. + // Zero-argument model functions are valid global function rates and + // need no wrapper. + const auto* function = findModelFunction(model, rate.name()); + return function != nullptr && !function->getArgs().empty() && + !expressionContainsModelFunctionReference(function->getExpression(), model); + } if (isCall) { const auto name = lowercaseName(rate.name()); if ((name == "functionproduct" && rate.args().size() == 2) || @@ -1022,6 +1339,7 @@ std::string XmlWriter::write(const ast::Model& model, const engine::GeneratedNet xml << writeReactionRules(model); xml << writeObservables(model); xml << writeFunctions(model); + xml << writeEnergyPatterns(model); xml << " \n"; xml << "\n"; @@ -1048,7 +1366,18 @@ std::string XmlWriter::writeMoleculeTypes(const ast::Model& model) { std::ostringstream xml; xml << " \n"; + std::vector moleculeTypes; + moleculeTypes.reserve(model.getMoleculeTypes().size()); for (const auto& mt : model.getMoleculeTypes()) { + moleculeTypes.push_back(&mt); + } + std::sort(moleculeTypes.begin(), moleculeTypes.end(), + [](const auto* left, const auto* right) { + return left->getName() < right->getName(); + }); + + for (const auto* mtPtr : moleculeTypes) { + const auto& mt = *mtPtr; xml << " \n"; xml << " \n"; @@ -1058,6 +1387,14 @@ std::string XmlWriter::writeMoleculeTypes(const ast::Model& model) { xml << ">\n"; xml << " \n"; for (const auto& state : comp.allowedStates) { + // `?` is a pattern wildcard, not a constructible state. + // NFinput::initMoleculeTypes ignores PLUS/MINUS while + // classifying numeric sites, but treats an emitted `?` + // as a string state and therefore changes the runtime + // representation of inferred integer components. + if (state == "?") { + continue; + } xml << " \n"; } xml << " \n"; @@ -1120,15 +1457,27 @@ std::string XmlWriter::writeSpecies(const ast::Model& model, const engine::Gener const auto& seed = model.getSeedSpecies()[i]; std::string spId = "S" + std::to_string(i + 1); + // BNG2 parses seed species as concrete SpeciesGraphs and applies + // its quasi-canonical molecule/component ordering before XML + // serialization. Canonicalize the parsed raw seed rather than + // serializing the AST graph directly: pattern graphs retain + // wildcard states for omitted seed states and their molecule + // nodes do not carry every source-level compartment spelling. + auto parsed = parsePattern(seed.getPattern()); + if (!seed.getCompartment().empty()) { + parsed.compartment = seed.getCompartment(); + } + canonicalizeParsedPattern(parsed); + const std::string patternStr = patternToBngl(parsed, seed.isConstant()); + auto amountValue = seed.getAmount().evaluate([&](const std::string& name) { return model.getParameters().evaluate(name); }, 0.0); xml << " \n"; + << "\" name=\"" << escapeXml(patternStr) << "\">\n"; - auto parsed = parsePattern(seed.getPattern()); xml << patternToXml(parsed, spId, " "); xml << " \n"; @@ -1218,11 +1567,23 @@ std::string XmlWriter::writeReactionRules(const ast::Model& model) { const ast::Expression& rate) { const bool isCall = rate.kind() == ast::ExpressionKind::Function || rate.kind() == ast::ExpressionKind::ObservableRef; - const bool isFunctionProduct = + const bool explicitFunctionProduct = isCall && lowercase(rate.name()) == "functionproduct" && rate.args().size() == 2; + const bool rawLocalFunctionProduct = + isBoundedRawLocalFunctionProduct(model, rate); + const bool isFunctionProduct = explicitFunctionProduct || rawLocalFunctionProduct; const auto* declaredFunction = modelFunction(rate.name()); const bool generatedRateFunction = needsGeneratedDynamicRateFunction(model, rate); + std::vector generatedArguments; + if (generatedRateFunction) { + std::set seenArguments; + std::string diagnostic; + if (!collectDynamicRateArguments( + rate, model, generatedArguments, seenArguments, diagnostic)) { + throw std::runtime_error(diagnostic); + } + } std::string type = "Ele"; if (generatedRateFunction) { type = "Function"; @@ -1242,7 +1603,8 @@ std::string XmlWriter::writeReactionRules(const ast::Model& model) { } xml << " \n"; xml << " \n"; - if (declaredFunction != nullptr) { - for (std::size_t index = 0; index < declaredFunction->getArgs().size(); ++index) { - const auto& argument = declaredFunction->getArgs()[index]; - const auto value = scopedMoleculeId(rule, rrId, argument); - xml << " \n"; + const auto& arguments = declaredFunction != nullptr + ? declaredFunction->getArgs() + : generatedArguments; + for (const auto& argument : arguments) { + const auto value = scopedMoleculeId(rule, rrId, argument); + if (value.empty()) { + throw std::runtime_error( + "dynamic rate argument '" + argument + + "' has no scoped reactant"); } + xml << " \n"; } xml << " \n"; xml << " \n"; @@ -1328,8 +1695,19 @@ std::string XmlWriter::writeReactionRules(const ast::Model& model) { const auto writeRule = [&](const ast::ReactionRule& rule, const std::string& rrId, const ast::Expression* rate) { + const bool isArrheniusRate = + rate != nullptr && rate->kind() == ast::ExpressionKind::Function && + lowercase(rate->name()) == "arrhenius" && rate->args().size() >= 2; xml << " \n"; + << "\" name=\"" << escapeXml(rule.getRuleName()) << "\""; + if (isArrheniusRate) { + // BNG2 represents a bidirectional Arrhenius rule with one rate + // law; retain the directionality explicitly for BNG3's XML + // compatibility loader while leaving older XML unchanged. + xml << " energyIncludeReverse=\"" + << (rule.isBidirectional() ? "1" : "0") << "\""; + } + xml << ">\n"; xml << " \n"; for (std::size_t index = 0; index < rule.getReactants().size(); ++index) { @@ -1592,12 +1970,30 @@ std::string XmlWriter::writeFunctions(const ast::Model& model) { ? name.substr(generatedPrefix.size()) : name; std::string diagnostic; + std::set localArguments; + if (!collectDynamicRateArguments( + expression, model, generated.arguments, localArguments, + diagnostic)) { + throw std::runtime_error(diagnostic); + } std::set activeFunctions; std::vector tableFunctions; - if (!expandDynamicRateExpression( - expression, model, generated.references, tableFunctions, - activeFunctions, generated.expandedExpression, diagnostic)) { - throw std::runtime_error(diagnostic); + if (generated.arguments.empty()) { + if (!expandDynamicRateExpression( + expression, model, generated.references, tableFunctions, + activeFunctions, generated.expandedExpression, diagnostic)) { + throw std::runtime_error(diagnostic); + } + } else { + collectTableFunctions(expression, tableFunctions); + const std::set localNames( + generated.arguments.begin(), generated.arguments.end()); + for (const auto& argument : generated.arguments) { + addFunctionReference(generated.references, argument, "Local"); + } + collectFunctionReferences( + expression, model, localNames, generated.references); + generated.expandedExpression = expressionForXml(expression); } if (tableFunctions.size() > 1) { throw std::runtime_error( @@ -1759,12 +2155,28 @@ std::string XmlWriter::writeFunctions(const ast::Model& model) { xml << " file=\"" << escapeXml(table->tableFilePath()) << "\""; } } + if (!generated.arguments.empty()) { + xml << " args=\""; + for (std::size_t index = 0; index < generated.arguments.size(); ++index) { + if (index != 0) xml << ","; + xml << escapeXml(generated.arguments[index]); + } + xml << "\""; + } xml << ">\n"; auto references = generated.references; for (const auto& [observableName, aliasName] : generated.observableAliases) { references.erase(observableName); addFunctionReference(references, aliasName, "Function"); } + if (!generated.arguments.empty()) { + xml << " \n"; + for (const auto& argument : generated.arguments) { + xml << " \n"; + } + xml << " \n"; + } xml << " \n"; for (const auto& [referenceName, referenceType] : references) { xml << " \n"; + + for (std::size_t index = 0; index < model.getEnergyPatterns().size(); ++index) { + const auto& energyPattern = model.getEnergyPatterns()[index]; + const auto id = "EP" + std::to_string(index + 1); + const auto patternId = id + "_P1"; + // The AST graph marks an omitted bond as a wildcard for matching, but + // its legacy string serializer cannot preserve that distinction. The + // source pattern retains BNG2's XML-facing 0/?/+ bond semantics. + auto parsed = parsePattern(energyPattern.getPattern()); + canonicalizeParsedPattern(parsed); + const auto patternText = patternToBngl(parsed, false); + + xml << " \n"; + xml << " \n"; + xml << patternToXml(parsed, patternId, " "); + xml << " \n"; + xml << " \n"; + } + + xml << " \n"; + return xml.str(); +} + } // namespace bng::io diff --git a/cpp/io/XmlWriter.hpp b/cpp/io/XmlWriter.hpp index 6ad250f0..f0667e8b 100644 --- a/cpp/io/XmlWriter.hpp +++ b/cpp/io/XmlWriter.hpp @@ -35,6 +35,7 @@ class XmlWriter { static std::string writeReactionRules(const ast::Model& model); static std::string writeObservables(const ast::Model& model); static std::string writeFunctions(const ast::Model& model); + static std::string writeEnergyPatterns(const ast::Model& model); // Pattern serialization helpers static std::string writePatternXml( diff --git a/cpp/main.cpp b/cpp/main.cpp index ffa3d6c9..73874834 100644 --- a/cpp/main.cpp +++ b/cpp/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -135,7 +136,15 @@ int main(int argc, char** argv) { if (verbose) { std::cerr << "[bng_cpp] executing actions for " << input << '\n'; } - bng::actions::ActionDispatch::execute(*result.model, input, verbose); + try { + bng::actions::ActionDispatch::execute(*result.model, input, verbose); + } catch (const std::exception& ex) { + std::cerr << input << ": action execution failed: " << ex.what() << '\n'; + ++failures; + } catch (...) { + std::cerr << input << ": action execution failed with an unknown exception\n"; + ++failures; + } } } } diff --git a/cpp/nfsim/NFcore/NFcore.hh b/cpp/nfsim/NFcore/NFcore.hh index 1c38c9d0..0ba84aa6 100644 --- a/cpp/nfsim/NFcore/NFcore.hh +++ b/cpp/nfsim/NFcore/NFcore.hh @@ -7,6 +7,7 @@ //Include stl IO and string functionality #include #include +#include #include //Include stl containers @@ -15,8 +16,10 @@ #include #include #include +#include #include #include +#include // Include various NFsim classes from other files #include "../NFscheduler/NFstream.h" #include "../NFutil/NFutil.hh" @@ -94,6 +97,7 @@ namespace NFcore // think of these as regular expressions for molecule comparison */ class ReactionClass; /* defines a reaction class, (in other words, a rxn rule) */ + class CompactPartnerPool; class Observable; /* object that moniters counts of things we want to keep track of */ @@ -108,6 +112,193 @@ namespace NFcore class SystemSnapshot; + /* Compact sorted membership IDs. Reaction membership is usually empty or + * contains one mapping per molecule, while std::set stores tree metadata for + * every reaction slot and mapping. Keep the first ID inline and allocate an + * overflow array only when a reaction has multiple mappings. */ + class MappingIdSet { + public: + class iterator { + friend class MappingIdSet; + MappingIdSet *owner; + unsigned int index; + iterator(MappingIdSet *owner, unsigned int index) + : owner(owner), index(index) {} + public: + iterator() : owner(0), index(0) {} + int& operator*() const { return owner->valueAt(index); } + iterator& operator++() { ++index; return *this; } + bool operator==(const iterator &other) const { + return owner == other.owner && index == other.index; + } + bool operator!=(const iterator &other) const { + return !(*this == other); + } + }; + + class const_iterator { + friend class MappingIdSet; + const MappingIdSet *owner; + unsigned int index; + const_iterator(const MappingIdSet *owner, unsigned int index) + : owner(owner), index(index) {} + public: + const_iterator() : owner(0), index(0) {} + const int& operator*() const { return owner->valueAt(index); } + const_iterator& operator++() { ++index; return *this; } + bool operator==(const const_iterator &other) const { + return owner == other.owner && index == other.index; + } + bool operator!=(const const_iterator &other) const { + return !(*this == other); + } + }; + + MappingIdSet() + : firstId(-1), extraIds(0), extraSize(0), extraCapacity(0) {} + MappingIdSet(const MappingIdSet &other) + : firstId(other.firstId), extraIds(0), + extraSize(other.extraSize), extraCapacity(0) { + if (extraSize > 0) { + extraCapacity = extraSize; + extraIds = new int[extraCapacity]; + for (unsigned int i = 0; i < extraSize; ++i) + extraIds[i] = other.extraIds[i]; + } + } + ~MappingIdSet() { delete [] extraIds; } + + MappingIdSet& operator=(const MappingIdSet &other) { + if (this == &other) return *this; + clear(); + firstId = other.firstId; + if (other.extraSize > 0) { + extraCapacity = other.extraSize; + extraSize = other.extraSize; + extraIds = new int[extraCapacity]; + for (unsigned int i = 0; i < extraSize; ++i) + extraIds[i] = other.extraIds[i]; + } + return *this; + } + + bool empty() const { return firstId < 0; } + size_t size() const { + return empty() ? 0 : static_cast(extraSize + 1); + } + iterator begin() { return iterator(this, 0); } + iterator end() { + return iterator(this, static_cast(size())); + } + const_iterator begin() const { return const_iterator(this, 0); } + const_iterator end() const { + return const_iterator(this, static_cast(size())); + } + void clear() { + firstId = -1; + extraSize = 0; + delete [] extraIds; + extraIds = 0; + extraCapacity = 0; + } + + pair insert(int id) { + if (empty()) { + firstId = id; + return make_pair(iterator(this, 0), true); + } + if (id == firstId) + return make_pair(iterator(this, 0), false); + if (id < firstId) { + int oldFirstId = firstId; + firstId = id; + insertExtra(oldFirstId); + return make_pair(iterator(this, 0), true); + } + unsigned int position = lowerBoundExtra(id); + if (position < extraSize && extraIds[position] == id) + return make_pair(iterator(this, position + 1), false); + insertExtraAt(position, id); + return make_pair(iterator(this, position + 1), true); + } + + size_t erase(int id) { + if (empty()) return 0; + if (id == firstId) { + if (extraSize == 0) { + firstId = -1; + return 1; + } + firstId = extraIds[0]; + for (unsigned int i = 1; i < extraSize; ++i) + extraIds[i - 1] = extraIds[i]; + --extraSize; + return 1; + } + if (id < firstId) return 0; + unsigned int position = lowerBoundExtra(id); + if (position == extraSize || extraIds[position] != id) + return 0; + for (unsigned int i = position + 1; i < extraSize; ++i) + extraIds[i - 1] = extraIds[i]; + --extraSize; + return 1; + } + + private: + int& valueAt(unsigned int index) { + return index == 0 ? firstId : extraIds[index - 1]; + } + const int& valueAt(unsigned int index) const { + return index == 0 ? firstId : extraIds[index - 1]; + } + unsigned int lowerBoundExtra(int id) const { + unsigned int position = 0; + while (position < extraSize && extraIds[position] < id) + ++position; + return position; + } + void ensureExtraCapacity(unsigned int required) { + if (required <= extraCapacity) return; + unsigned int newCapacity = extraCapacity == 0 ? 2 : extraCapacity * 2; + while (newCapacity < required) newCapacity *= 2; + int *newIds = new int[newCapacity]; + for (unsigned int i = 0; i < extraSize; ++i) + newIds[i] = extraIds[i]; + delete [] extraIds; + extraIds = newIds; + extraCapacity = newCapacity; + } + void insertExtraAt(unsigned int position, int id) { + ensureExtraCapacity(extraSize + 1); + for (unsigned int i = extraSize; i > position; --i) + extraIds[i] = extraIds[i - 1]; + extraIds[position] = id; + ++extraSize; + } + void insertExtra(int id) { + insertExtraAt(lowerBoundExtra(id), id); + } + + int firstId; + int *extraIds; + unsigned int extraSize; + unsigned int extraCapacity; + }; + + /* Endpoint state changes exposed by compact EnergyPattern reactions. */ + struct IncrementalMembershipChange { + IncrementalMembershipChange() : + moleculeType1(0), componentIndex1(-1), isBoundAfter1(false), + moleculeType2(0), componentIndex2(-1), isBoundAfter2(false) {} + MoleculeType *moleculeType1; + int componentIndex1; + bool isBoundAfter1; + MoleculeType *moleculeType2; + int componentIndex2; + bool isBoundAfter2; + }; + //exception for the handling of local functions and mapping sets class LocalFunctionException: public exception @@ -249,6 +440,8 @@ namespace NFcore bool isOutputtingBinary() { return useBinaryOutput; }; double getCurrentTime() const { return current_time; }; double * getCurrentTimePtr() { return ¤t_time; }; + /* Set the absolute simulation time before prepareForSimulation(). */ + void setCurrentTime(double time); int getGlobalMoleculeLimit() const { return globalMoleculeLimit; }; void setHasTimeDependentFunctions(bool val) { hasTimeDependentFunctions = val; } @@ -390,6 +583,16 @@ namespace NFcore //double calculateMeanCount(MoleculeType *m); void update_A_tot(ReactionClass *r, double old_a, double new_a); + void beginDeferredMembershipPropensityUpdates(); + void deferMembershipPropensityUpdate(ReactionClass *r, double oldA); + void deferCompactPartnerPoolUpdate( + CompactPartnerPool *pool, int oldPoolSize, int newPoolSize); + void endDeferredMembershipPropensityUpdates(); + bool isDeferringMembershipPropensityUpdates() const { + return deferringMembershipPropensityUpdates; + } + void updateCompactPartnerPoolBatch( + CompactPartnerPool *pool, int oldPoolSize, int newPoolSize); @@ -416,10 +619,12 @@ namespace NFcore * a file divided equally throughout the elapsed time */ double sim(double time, long int sampleTimes, bool verbose); - /* run the simulation up until the stopping time (but not exceding the stopping time. This - * will not output anything to file (so must be done manually) and returns the current time - * of the simulation, which will always be less than the stopping time */ + /* Run up to, but not across, the stopping time without file output. */ double stepTo(double stoppingTime); + /* API compatibility form: native System::sim may fire the pending event + * that crosses its final output endpoint; ordinary stepTo callers retain + * the exclusive boundary above. */ + double stepTo(double stoppingTime, bool includeEndpointEvent); void singleStep(); @@ -448,9 +653,16 @@ namespace NFcore void updateSystemWithNewParameters(); void printAllParameters(); - // RNG management for thread-safe, deterministic simulations - void seedRNG(unsigned long seed) { rng_.seed(seed); } - NfsimRNG& getRNG() { return rng_; } + // RNG management for thread-safe, deterministic simulations. + // Current NFsim keeps legacy global draws for molecule/mapping + // selection, so preserve that source split in a second + // per-System stream without reintroducing shared global state. + void seedRNG(unsigned long seed) { + rng_.seed(seed); + mapping_rng_.seed(seed); + } + NfsimRNG& getRNG() { return rng_; } + NfsimRNG& getMappingRNG() { return mapping_rng_; } NFstream& getOutputFileStream(); NFstream& getReactionFileStream(); @@ -613,6 +825,8 @@ namespace NFcore double a_tot; /*< the sum of all a's (propensities) of all reactions */ double current_time; /*< keeps track of the simulation time */ ReactionClass * nextReaction; /*< keeps track of the next reaction to fire */ + bool pendingStepEventValid = false; /*< cached waiting-time draw for stepTo() */ + double pendingStepEventTime = 0.0; /*< absolute event time for cached stepTo() draw */ // max CPU time for simulation double max_cpu_time; @@ -622,6 +836,10 @@ namespace NFcore double recompute_A_tot(); double getNextRxn(); double getMaxCpuTime() const { return max_cpu_time; }; + void invalidateStepToCache() { + pendingStepEventValid = false; + pendingStepEventTime = 0.0; + } /////////////////////////////////////////////////////////////////////////// @@ -653,12 +871,29 @@ namespace NFcore //Data structure that performs the selection of the next reaction class ReactionSelector * selector; + struct DeferredCompactPartnerPoolUpdate { + DeferredCompactPartnerPoolUpdate() : + pool(0), oldPoolSize(0), newPoolSize(0) {} + CompactPartnerPool *pool; + int oldPoolSize; + int newPoolSize; + }; + bool deferringMembershipPropensityUpdates = false; + vector deferredMembershipReactions; + vector deferredMembershipOldPropensities; + vector + deferredCompactPartnerPoolUpdates; + unsigned long long deferredMembershipUpdateGeneration = 0; // To look up connected reactions quickly vector > connectedReactions; - // Per-instance random number generator for thread safety - NfsimRNG rng_; + // Per-instance random number generator for thread safety + NfsimRNG rng_; + + // Source-compatible stream for legacy molecule/mapping selection + // draws that remain separate from the System reaction stream. + NfsimRNG mapping_rng_; // AS2023 - sets the default log buffer size to 10000 firings. int log_buffer_size = 10000; @@ -809,6 +1044,15 @@ namespace NFcore int getMoleculeCount() const; int getReactionCount() const { return reactions.size(); }; + /* Mapping storage is dense only over reaction registrations that can + * attach a MappingSet to a molecule. Compact EnergyPattern + * partner-side registrations use a shared pool instead. */ + int getReactionMappingIndex(int reactionIndex) const { + return reactionMappingIndices[reactionIndex]; + }; + int getReactionMappingCount() const { + return reactionMappingCount; + }; int getRxnIndex(ReactionClass * rxn, int rxnPosition); @@ -816,6 +1060,7 @@ namespace NFcore //Functions to generate molecules, remove molecules at the beginning //or during a running simulation Molecule *genDefaultMolecule(Compartment *c = 0); + CompactPartnerPool *getOrCreateCompactPartnerPool(int componentIndex); void removeAllMolecules(); @@ -829,6 +1074,7 @@ namespace NFcore //Adds the basic components that this MoleculeType needs to reference void addReactionClass(ReactionClass * r, int rPosition); + bool canSkipIndirectMembership(ReactionClass *firedReaction) const; void addMolObs(MoleculesObservable * mo) { molObs.push_back(mo); }; //could add check here to make sure observable is of this type int createComplex(Molecule *m) { return (system->getAllComplexes()).createComplex(m); }; void addTemplateMolecule(TemplateMolecule *t); @@ -842,12 +1088,15 @@ namespace NFcore /* updates a molecules membership (assumes molecule is of type this) */ - void updateRxnMembership(Molecule * m); + void updateRxnMembership(Molecule * m, + ReactionClass *firedReaction = 0, + bool directProduct = false); /* Updates only molecule membership in connected reactions. * The connected reactions are inferred at the simulation start. * Arvind Rasi Subramaniam */ - void updateConnectedRxnMembership(Molecule * m, ReactionClass * r); + void updateConnectedRxnMembership(Molecule * m, ReactionClass * r, + bool directProduct = false); /* auto populate with default molecules */ void populateWithDefaultMolecules(int moleculeCount); @@ -953,6 +1202,20 @@ namespace NFcore vector reactions; /* List of reactions that this type can be involved with */ vector reactionPositions; /* the position in the reaction for this type of molecule */ + vector reactionMappingIndices; + int reactionMappingCount; + vector compactPartnerPools; + + /* Static candidate index for weighted simple EnergyPattern reactions. + * Each bitset is ordered by reaction index, so iterating set bits keeps + * the native reaction order without scanning every registered reaction. */ + vector > compactEnergyCenterCandidateBits; + vector > compactEnergyContextCandidateBits; + vector > compactPartnerCandidateBits; + vector > compactPartnerReactionIndices; + vector compactEnergyContextMinimumRequiredBits; + vector nonCompactMembershipCandidateBits; + bool hasCompactEnergyMembershipIndex; vector indexOfDORrxns; @@ -964,6 +1227,21 @@ namespace NFcore ReactionClass *rxn; /*used so we don't need to redeclare this at every call to updateRxnMembership */ + /* Cache direct-product membership decisions when every reaction on this + * molecule type proves that its decision is type-invariant. Dense + * decisions use one byte per reaction; sparse decisions retain only the + * ordered affected reaction indices. */ + struct DirectMembershipDecisionCacheEntry { + DirectMembershipDecisionCacheEntry() : useReactionIndices(false) {} + std::vector decisions; + std::vector reactionIndices; + bool useReactionIndices; + }; + std::unordered_map + directMembershipDecisionCache; + std::unordered_map + directMembershipDecisionCacheSafe; + private: @@ -1037,6 +1315,7 @@ namespace NFcore /////////////////////////////////////////////////////////////////////// int getComponentState(int cIndex) const { return component[cIndex]; }; int getComponentIndexOfBond(int cIndex) const { return indexOfBond[cIndex]; }; + std::uint64_t getBoundComponentMask() const { return boundComponentMask; }; void setComponentState(int cIndex, int newValue); void setComponentState(string cName, int newValue); @@ -1057,30 +1336,46 @@ namespace NFcore int getRxnListMappingId(int rxnIndex) { //return rxnListMappingId[rxnIndex]; - return (rxnListMappingId2[rxnIndex].size() > 0) ? *rxnListMappingId2[rxnIndex].begin() : -1; //JJT: changing to handle multiple mappings per reaction + int mappingIndex = parentMoleculeType->getReactionMappingIndex( + rxnIndex); + if (mappingIndex < 0) return -1; + return (rxnListMappingId2[mappingIndex].size() > 0) ? + *rxnListMappingId2[mappingIndex].begin() : -1; //JJT: changing to handle multiple mappings per reaction }; - set getRxnListMappingSet(int rxnIndex){ - - return rxnListMappingId2[rxnIndex]; + const MappingIdSet& getRxnListMappingSet(int rxnIndex) const { + int mappingIndex = parentMoleculeType->getReactionMappingIndex( + rxnIndex); + if (mappingIndex < 0) { + static const MappingIdSet empty; + return empty; + } + return rxnListMappingId2[mappingIndex]; } bool setRxnListMappingId(int rxnIndex, int rxnListMappingId) { - if(rxnListMappingId == -1){ - this->rxnListMappingId2[rxnIndex].clear(); - //this->rxnListMappingId3[rxnIndex].clear(); - return true; - } - else{ - pair::iterator,bool> it = this->rxnListMappingId2[rxnIndex].insert(rxnListMappingId); //JJT: using a set* instead of int* to deal with multiple mappings per reaction - return it.second; //JJT: return whether it is a new insert or not - } + int mappingIndex = parentMoleculeType->getReactionMappingIndex( + rxnIndex); + if (mappingIndex < 0) return rxnListMappingId == -1; + if(rxnListMappingId == -1){ + this->rxnListMappingId2[mappingIndex].clear(); + //this->rxnListMappingId3[rxnIndex].clear(); + return true; + } + else{ + pair it = + this->rxnListMappingId2[mappingIndex].insert(rxnListMappingId); //JJT: using a compact set instead of int* to deal with multiple mappings per reaction + return it.second; //JJT: return whether it is a new insert or not + } }; void deleteRxnListMappingId(int rxnIndex, int rxnListMappingId){ - rxnListMappingId2[rxnIndex].erase(rxnListMappingId); + int mappingIndex = parentMoleculeType->getReactionMappingIndex( + rxnIndex); + if (mappingIndex >= 0) + rxnListMappingId2[mappingIndex].erase(rxnListMappingId); } /* set functions for states, bonds, and complexes */ @@ -1115,7 +1410,8 @@ namespace NFcore /* function that tells this molecule that it changed states or bonds * and it should update its reaction membership */ - void updateRxnMembership(ReactionClass * r, bool useConnectivity); + void updateRxnMembership(ReactionClass * r, bool useConnectivity, + bool directProduct = false); void removeFromObservables(); void addToObservables(); @@ -1196,6 +1492,7 @@ namespace NFcore int numOfComponents; Molecule **bond; int *indexOfBond; /* gives the index of the component that is bonded to this molecule */ + std::uint64_t boundComponentMask; //////////// keep track of local function values @@ -1207,7 +1504,7 @@ namespace NFcore //Used to keep track of which reactions this molecule is in... - set* rxnListMappingId2; + MappingIdSet* rxnListMappingId2; map, int>* rxnListMappingId3; int nReactions; @@ -1228,6 +1525,113 @@ namespace NFcore + /* Simple compact EnergyPattern forward rules with the same partner + * molecule/component have identical partner-side eligibility. Keep that + * eligibility once per molecule type instead of allocating one MappingSet + * and one pointer list per reaction. MappingSets used by the reaction + * transformation itself remain per-reaction scratch objects. */ + class CompactPartnerPool { + public: + CompactPartnerPool() : molecules(), positions(), + lastRefreshedMolecule(0), lastRefreshMatches(false), + hasLastRefresh(false), registeredReactions(), + compactPartnerPoolBatchable(true) {} + + int size() const { return static_cast(molecules.size()); } + void registerReaction(ReactionClass *reaction, bool batchable) { + if (reaction == 0) return; + registeredReactions.push_back(reaction); + compactPartnerPoolBatchable = + compactPartnerPoolBatchable && batchable; + } + const std::vector &getRegisteredReactions() const { + return registeredReactions; + } + bool supportsBatchUpdate() const { + return !registeredReactions.empty() && + compactPartnerPoolBatchable; + } + Molecule *getByIndex(unsigned int index) const { + return index < molecules.size() ? molecules[index] : 0; + } + bool contains(Molecule *molecule, unsigned int moleculeSlot) const { + if (moleculeSlot >= positions.size()) return false; + int position = positions[moleculeSlot]; + return position >= 0 && + static_cast(position) < molecules.size() && + molecules[position] == molecule; + } + bool add(Molecule *molecule, unsigned int moleculeSlot) { + invalidateRefreshCache(); + return addRaw(molecule, moleculeSlot); + } + bool remove(Molecule *molecule, unsigned int moleculeSlot) { + invalidateRefreshCache(); + return removeRaw(molecule, moleculeSlot); + } + bool refresh(Molecule *molecule, unsigned int moleculeSlot, + bool matches) { + if (molecule == 0) return false; + if (hasLastRefresh && lastRefreshedMolecule == molecule && + lastRefreshMatches == matches) + return false; + lastRefreshedMolecule = molecule; + lastRefreshMatches = matches; + hasLastRefresh = true; + return matches ? addRaw(molecule, moleculeSlot) + : removeRaw(molecule, moleculeSlot); + } + + private: + void invalidateRefreshCache() { + hasLastRefresh = false; + lastRefreshedMolecule = 0; + } + void ensurePositionCapacity(unsigned int moleculeSlot) { + if (moleculeSlot >= positions.size()) + positions.resize(static_cast(moleculeSlot) + 1, -1); + } + bool addRaw(Molecule *molecule, unsigned int moleculeSlot) { + if (molecule == 0) return false; + ensurePositionCapacity(moleculeSlot); + int position = positions[moleculeSlot]; + if (position >= 0) return false; + positions[moleculeSlot] = static_cast(molecules.size()); + molecules.push_back(molecule); + return true; + } + bool removeRaw(Molecule *molecule, unsigned int moleculeSlot) { + if (moleculeSlot >= positions.size()) return false; + int storedPosition = positions[moleculeSlot]; + if (storedPosition < 0 || + static_cast(storedPosition) >= molecules.size() || + molecules[storedPosition] != molecule) + return false; + unsigned int position = static_cast(storedPosition); + unsigned int last = static_cast(molecules.size() - 1); + if (position != last) { + Molecule *replacement = molecules[last]; + molecules[position] = replacement; + positions[static_cast(replacement->getMolListId())] = + static_cast(position); + } + molecules.pop_back(); + positions[moleculeSlot] = -1; + return true; + } + std::vector molecules; + /* MoleculeList assigns a stable slot ID to every molecule object. A + * dense reverse index avoids one unordered_map node and allocation per + * eligible partner while retaining O(1) membership updates. */ + std::vector positions; + Molecule *lastRefreshedMolecule; + bool lastRefreshMatches; + bool hasLastRefresh; + std::vector registeredReactions; + bool compactPartnerPoolBatchable; + }; + + //! Abstract Base Class that defines the interface for all reaction rules. /*! A ReactionClass represents the set of reactions implied by a single reaction @@ -1286,7 +1690,7 @@ namespace NFcore void setTraversalLimit(int limit) { this->traversalLimit = limit; }; - double get_a() const { return a; }; + virtual double get_a() const { return a; }; virtual void printDetails() const; void fire(double random_A_number); // AS2023 - additional call sig to use with reaction firing tracking. The call @@ -1310,9 +1714,78 @@ namespace NFcore virtual void init() = 0; //called when the reaction is added to the system virtual void prepareForSimulation() = 0; //called once everything is added to the system virtual bool tryToAdd(Molecule *m, unsigned int reactantPos) = 0; + /* Membership refresh callers already walk a MoleculeType's reaction + * vector, so the local reaction-list index is available without the + * system-wide rxnId/position lookup. */ + virtual bool tryToAddWithIndex(Molecule *m, unsigned int reactantPos, + int rxnIndex) { + (void)rxnIndex; + return tryToAdd(m, reactantPos); + } + /* Same membership update as tryToAdd(), with a conservative indication + * of whether the reaction's propensity may have changed. */ + virtual bool tryToAddAndReportChange( + Molecule *m, unsigned int reactantPos) { + tryToAdd(m, reactantPos); + return true; + } + virtual bool tryToAddAndReportChangeWithIndex( + Molecule *m, unsigned int reactantPos, int rxnIndex) { + (void)rxnIndex; + return tryToAddAndReportChange(m, reactantPos); + } virtual void remove(Molecule *m, unsigned int reactantPos) = 0; virtual double update_a() = 0; + virtual bool usesIncrementalMembership() const { return false; } + virtual bool membershipDecisionIsTypeInvariant() const { return false; } + virtual bool getIncrementalMembershipChange( + IncrementalMembershipChange &change) const { + (void)change; + return false; + } + virtual bool getCompactMembershipIndexInfo( + unsigned int reactantPos, + int &reactionCenterComponent, + std::uint64_t &contextComponentMask, + unsigned int &minimumContextComponents) const { + (void)reactantPos; + (void)reactionCenterComponent; + (void)contextComponentMask; + (void)minimumContextComponents; + return false; + } + virtual bool supportsSparseSelection() const { return false; } + /* Whether the product list can be limited to explicitly mapped + * molecules for this firing. */ + virtual bool canUseDirectProductList() const { return false; } + virtual bool canSkipIndirectMembership( + ReactionClass *firedReaction) const { + (void)firedReaction; + return false; + } + virtual bool shouldUpdateMembership( + Molecule *m, ReactionClass *firedReaction, + bool directProduct) const { + (void)m; + (void)firedReaction; + (void)directProduct; + return true; + } + virtual bool shouldUpdateMembershipForChange( + Molecule *m, + const IncrementalMembershipChange &change) const { + (void)m; + (void)change; + return true; + } + /* Give specialized reactions a chance to reject a stale mapping before + * product preparation and membership work. The default preserves the + * legacy transformation-time checks. */ + virtual bool checkPreFireConditions(MappingSet **mappingSets) const { + (void)mappingSets; + return true; + } @@ -1323,6 +1796,41 @@ namespace NFcore virtual int getReactantCount(unsigned int reactantIndex) const = 0; virtual int getCorrectedReactantCount(unsigned int reactantIndex) const = 0; virtual void printFullDetails() const = 0; + /* Optional shared partner-pool hooks used by compact EnergyPattern + * reactions. Legacy reaction classes retain the ordinary path. */ + virtual bool getCompactPartnerPoolInfo( + unsigned int reactantPos, int &partnerComponent) const { + (void)reactantPos; + (void)partnerComponent; + return false; + } + virtual bool refreshCompactPartnerPool( + Molecule *m, unsigned int reactantPos) { + (void)m; + (void)reactantPos; + return false; + } + virtual bool supportsCompactPartnerPoolUpdate() const { return false; } + virtual bool supportsCompactPartnerPoolScale() const { return false; } + virtual CompactPartnerPool *getCompactPartnerPool() const { return 0; } + virtual double getCompactPartnerPoolCoefficient() const { return 0.0; } + virtual double update_a_for_compact_partner_pool(int poolSize) { + (void)poolSize; + return update_a(); + } + /* Whether membership mutations can defer update_a() until all direct + * products from the current compact EnergyPattern event are processed. */ + virtual bool supportsDeferredMembershipUpdate() const { return false; } + /* Mark this reaction once per deferred membership batch. */ + bool markDeferredMembershipUpdate(unsigned long long generation) { + if (deferredMembershipUpdateGeneration == generation) + return false; + deferredMembershipUpdateGeneration = generation; + return true; + } + bool hasDeferredMembershipUpdate(unsigned long long generation) const { + return deferredMembershipUpdateGeneration == generation; + } void setMatchOnce(unsigned int reactantIndex, bool val) { if (reactantIndex < n_reactants) matchOncePerReactant[reactantIndex] = val; @@ -1330,6 +1838,9 @@ namespace NFcore bool getMatchOnce(unsigned int reactantIndex) const { return (reactantIndex < n_reactants) ? matchOncePerReactant[reactantIndex] : false; } + bool getCountsPerComplex(unsigned int reactantIndex) const { + return (reactantIndex < n_reactants) ? contextCountsPerComplex[reactantIndex] : false; + } void setRxnId(int rxnId) { this->rxnId = rxnId; }; @@ -1370,6 +1881,7 @@ namespace NFcore // Called from within Transformation Set to check connectivity bool areMoleculeTypeAndComponentPresent(MoleculeType * mt, int cIndex); bool isTemplateCompatible(TemplateMolecule * t); + bool isDirectProductMolecule(Molecule *molecule) const; protected: virtual void pickMappingSets(double randNumber) const=0; @@ -1391,6 +1903,7 @@ namespace NFcore string baseRateParameterName; double a; unsigned int fireCounter; + unsigned long long deferredMembershipUpdateGeneration = 0; unsigned int traversalLimit; @@ -1414,6 +1927,17 @@ namespace NFcore list products; list ::iterator molIter; + /* Preserve the molecules explicitly selected by the fired mapping sets. + * Membership refresh can recycle those mappings while rebuilding the + * reactant trees, so the direct-product decision must not depend on the + * mapping arrays after transformation. */ + vector directProductMoleculeList; + /* The BNG3 direct-product vector is also needed in stable mapping order + * by compact energy reactions. Allocate the hash lookup lazily for the + * connectivity path, mirroring nfsim commit 2b3c643 without replacing + * the ordered adapter representation. */ + unordered_set *directProductMolecules; + bool directProductMoleculeSetValid; // remember the molecule type of each product molecule a with typeII dependencies list typeII_products; @@ -1437,6 +1961,9 @@ namespace NFcore /* flag for MatchOnce */ bool *matchOncePerReactant; + /* flag for pure context reactants counted once per complex */ + bool *contextCountsPerComplex; + /* if population reactants are identical, this is the discrete * count correction for calculating the ratelaw */ diff --git a/cpp/nfsim/NFcore/energyPattern.cpp b/cpp/nfsim/NFcore/energyPattern.cpp index 41baa271..f4058d2e 100644 --- a/cpp/nfsim/NFcore/energyPattern.cpp +++ b/cpp/nfsim/NFcore/energyPattern.cpp @@ -28,6 +28,92 @@ void EnergyFunction::addEnergyPattern(const EnergyPatternInfo &ep) { patterns.push_back(ep); } +bool EnergyFunction::getBindingContext( + const string &molType1, const string &bindSite1, + const string &molType2, const string &bindSite2, + EnergyBindingContext &context +) const { + context.baseEnergy = 0.0; + context.conditions.clear(); + context.conditionalTerms.clear(); + + const vector relevant = findRelevantPatternsForBinding( + molType1, bindSite1, molType2, bindSite2); + if (relevant.empty()) return false; + + vector alwaysPatterns; + vector conditionalPatterns; + + for (int pi : relevant) { + const EnergyPatternInfo &ep = patterns[pi]; + bool hasExtraContext = false; + int weightedMoleculeCount = 0; + + for (const auto &mol : ep.molecules) { + if (mol.typeName == molType1) ++weightedMoleculeCount; + } + for (const auto &mol : ep.molecules) { + const bool isReactantType = + mol.typeName == molType1 || mol.typeName == molType2; + if (!isReactantType) { + hasExtraContext = true; + break; + } + for (const auto &comp : mol.components) { + if (mol.typeName == molType1 && comp.name == bindSite1) continue; + if (mol.typeName == molType2 && comp.name == bindSite2) continue; + if (comp.isBound || !comp.stateConstraint.empty()) { + hasExtraContext = true; + break; + } + } + if (hasExtraContext) break; + } + + /* The compact evaluator receives one selected reaction-center + * molecule. A second molecule of that same type could contribute a + * context match that is not a property of the selected molecule, so + * retain the materialized expansion for that topology. */ + if (weightedMoleculeCount != 1) return false; + + if (hasExtraContext) { + conditionalPatterns.push_back(pi); + } else { + alwaysPatterns.push_back(pi); + } + } + + for (int pi : alwaysPatterns) context.baseEnergy += patterns[pi].energyValue; + + context.conditions = extractContextConditions( + conditionalPatterns, molType1, bindSite1, molType2, bindSite2); + + /* Keep the descriptor within the 64-bit mask used by the compact + * evaluator. */ + if (context.conditions.empty() || context.conditions.size() >= 64) return false; + + for (int pi : conditionalPatterns) { + std::uint64_t conditionMask = 0; + for (unsigned int ci = 0; ci < context.conditions.size(); ++ci) { + const vector &gated = context.conditions[ci].gatedPatternIndices; + if (find(gated.begin(), gated.end(), pi) != gated.end()) { + conditionMask |= (std::uint64_t(1) << ci); + } + } + + /* A conditional pattern with no representable reactant-side + * condition is not safe to execute incrementally. */ + if (conditionMask == 0) return false; + + EnergyPatternTerm term; + term.energyValue = patterns[pi].energyValue; + term.conditionMask = conditionMask; + context.conditionalTerms.push_back(term); + } + + return !context.conditionalTerms.empty(); +} + /* * Find energy patterns relevant to a binding rule. * diff --git a/cpp/nfsim/NFcore/energyPattern.hh b/cpp/nfsim/NFcore/energyPattern.hh index 51a256ea..8849b8e3 100644 --- a/cpp/nfsim/NFcore/energyPattern.hh +++ b/cpp/nfsim/NFcore/energyPattern.hh @@ -31,6 +31,7 @@ #include #include #include +#include #include namespace NFcore { @@ -115,6 +116,24 @@ namespace NFcore { std::vector constraints; }; + /* + * Compact representation of the context-dependent part of a binding + * energy rule. The legacy path materializes one ExpandedRuleInfo for + * every boolean context combination. A factorized rule can instead keep + * these terms and evaluate the matching rate factor for the selected + * mapping only. + */ + struct EnergyPatternTerm { + double energyValue; + std::uint64_t conditionMask; + }; + + struct EnergyBindingContext { + double baseEnergy; + std::vector conditions; + std::vector conditionalTerms; + }; + /* * EnergyFunction: holds all parsed energy patterns and parameters, * and implements the Sekar expansion algorithm. @@ -131,6 +150,17 @@ namespace NFcore { int getNumPatterns() const { return (int)patterns.size(); } const EnergyPatternInfo& getPattern(int i) const { return patterns[i]; } + /* + * Build the compact context descriptor used by the incremental + * binding path. A false return leaves the caller free to use the + * materialized expansion for unsupported context topologies. + */ + bool getBindingContext( + const std::string &molType1, const std::string &bindSite1, + const std::string &molType2, const std::string &bindSite2, + EnergyBindingContext &context + ) const; + /* * Core expansion algorithm (Sekar §3.4). * diff --git a/cpp/nfsim/NFcore/molecule.cpp b/cpp/nfsim/NFcore/molecule.cpp index 0694d2ed..f7070688 100644 --- a/cpp/nfsim/NFcore/molecule.cpp +++ b/cpp/nfsim/NFcore/molecule.cpp @@ -39,6 +39,7 @@ Molecule::Molecule(MoleculeType * parentMoleculeType, int listId, Compartment * bond[b]=0; indexOfBond[b]=NOBOND; hasVisitedBond[b] = false; } + boundComponentMask = 0; hasVisitedMolecule = false; @@ -86,7 +87,9 @@ void Molecule::prepareForSimulation() { if(isPrepared) return; nReactions = parentMoleculeType->getReactionCount(); - this->rxnListMappingId2 = new set[nReactions]; + int mappingCount = parentMoleculeType->getReactionMappingCount(); + this->rxnListMappingId2 = mappingCount > 0 + ? new MappingIdSet[mappingCount] : 0; isPrepared = true; @@ -119,6 +122,7 @@ void Molecule::setUpLocalFunctionList() + //Used so that this molecule can remember what its local function was //evaluated to. Only TypeI local functions are set up in this way void Molecule::setLocalFunctionValue(double newValue,int localFunctionIndex) { @@ -162,13 +166,16 @@ LocalFunction * Molecule::getLocalFunction(int localFunctionIndex) { -void Molecule::updateRxnMembership(ReactionClass * r, bool useConnectivity) +void Molecule::updateRxnMembership(ReactionClass * r, bool useConnectivity, + bool directProduct) { if (useConnectivity) { - parentMoleculeType->updateConnectedRxnMembership(this, r); + parentMoleculeType->updateConnectedRxnMembership( + this, r, directProduct); } else { - parentMoleculeType->updateRxnMembership(this); + parentMoleculeType->updateRxnMembership( + this, r, directProduct); } } @@ -203,9 +210,9 @@ void Molecule::updateDORRxnValues() //If we are in this reaction, then we have to update our value... if(getRxnListMappingId(rxnIndex)>=0) { //iterate over all mappings - set tempSet = getRxnListMappingSet(rxnIndex); + const MappingIdSet& tempSet = getRxnListMappingSet(rxnIndex); //iterate over all agent-mappings for the same reaction - for(set::iterator it= tempSet.begin();it!= tempSet.end(); ++it){ + for(MappingIdSet::const_iterator it= tempSet.begin();it!= tempSet.end(); ++it){ //Careful here! remember to update the propensity of this //reaction in the system after we notify of the rate factor change! @@ -480,6 +487,10 @@ void Molecule::bind(Molecule *m1, int cIndex1, Molecule *m2, int cIndex2) m1->indexOfBond[cIndex1] = cIndex2; m2->indexOfBond[cIndex2] = cIndex1; + if (cIndex1 < 64) + m1->boundComponentMask |= (std::uint64_t(1) << cIndex1); + if (cIndex2 < 64) + m2->boundComponentMask |= (std::uint64_t(1) << cIndex2); //Handle Complexes if(m1->useComplex) @@ -530,6 +541,12 @@ vector Molecule::unbind(Molecule *m1, int cIndex) m1->indexOfBond[cIndex] = NOINDEX; m2->indexOfBond[cIndex2] = NOINDEX; + if (cIndex < 64) + m1->boundComponentMask &= + ~(std::uint64_t(1) << cIndex); + if (cIndex2 < 64) + m2->boundComponentMask &= + ~(std::uint64_t(1) << cIndex2); //Handle Complexes if(m1->useComplex) @@ -774,6 +791,3 @@ void Molecule::printMoleculeList(list &members) cout<<"_u"<<(*molIter)->getUniqueID()< #include "NFcore.hh" +#if defined(_MSC_VER) +#include +#endif + using namespace std; using namespace NFcore; +namespace { + +unsigned int compactMembershipBitCount(std::uint64_t value) +{ + unsigned int count = 0; + while (value != 0) { + value &= (value - 1); + ++count; + } + return count; +} + +void setCompactMembershipCandidateBit( + vector &bits, unsigned int reactionIndex) +{ + const std::size_t wordIndex = reactionIndex >> 6; + if (bits.size() <= wordIndex) + bits.resize(wordIndex + 1, 0); + bits[wordIndex] |= + (std::uint64_t(1) << (reactionIndex & 63)); +} + +unsigned int compactMembershipTrailingZeroCount(std::uint64_t value) +{ +#if defined(_MSC_VER) + unsigned long bit = 0; + _BitScanForward64(&bit, value); + return static_cast(bit); +#else + return static_cast(__builtin_ctzll(value)); +#endif +} + +bool compactMembershipBitIsSet( + const vector &bits, unsigned int reactionIndex) +{ + const std::size_t wordIndex = reactionIndex >> 6; + return wordIndex < bits.size() && + (bits[wordIndex] & + (std::uint64_t(1) << (reactionIndex & 63))) != 0; +} + +} + @@ -149,10 +197,29 @@ void MoleculeType::init( //Register myself with the system, and get an ID number this->system = system; this->type_id = this->system->addMoleculeType(this); + reactionMappingIndices.clear(); + reactionMappingCount = 0; + compactPartnerPools.assign(numOfComponents, + static_cast(0)); + compactEnergyCenterCandidateBits.assign( + numOfComponents, vector()); + compactEnergyContextCandidateBits.assign( + numOfComponents, vector()); + compactPartnerCandidateBits.assign( + numOfComponents, vector()); + compactPartnerReactionIndices.assign( + numOfComponents, vector()); + compactEnergyContextMinimumRequiredBits.assign(numOfComponents, 0); + nonCompactMembershipCandidateBits.clear(); + hasCompactEnergyMembershipIndex = false; mList = new MoleculeList(this,2,system->getGlobalMoleculeLimit()); n_eqComp = 0; + eqCompOriginalName = nullptr; + eqCompSizes = nullptr; + eqCompName = nullptr; + eqCompIndex = nullptr; indexToEqClass = nullptr; } @@ -182,6 +249,10 @@ MoleculeType::~MoleculeType() if (indexToEqClass) { delete [] indexToEqClass; } + for (vector::iterator it = compactPartnerPools.begin(); + it != compactPartnerPools.end(); ++it) { + delete *it; + } @@ -404,12 +475,13 @@ void MoleculeType::removeMoleculeFromRunningSystem(Molecule *&m) void MoleculeType::removeAllMolecules() { - // Iterate through all molecules and remove them - // We need to loop backwards because remove() removes by swapping with the last element + // Iterate through all molecules and remove them. Loop backwards because + // remove() removes by swapping with the last element. MoleculeList owns its + // preallocated Molecule objects, so leave the inactive slots in place for + // reuse and for MoleculeList::~MoleculeList() to release. for (int m = mList->size() - 1; m >= 0; m--) { Molecule *mol = mList->at(m); removeMoleculeFromRunningSystem(mol); - delete mol; // Free the memory to prevent leaks } } @@ -438,6 +510,16 @@ int MoleculeType::getMoleculeCount() const { return mList->size(); } +CompactPartnerPool *MoleculeType::getOrCreateCompactPartnerPool( + int componentIndex) +{ + if (componentIndex < 0 || componentIndex >= numOfComponents) + return 0; + if (compactPartnerPools[componentIndex] == 0) + compactPartnerPools[componentIndex] = new CompactPartnerPool(); + return compactPartnerPools[componentIndex]; +} + void MoleculeType::addTemplateMolecule(TemplateMolecule *t) { @@ -485,8 +567,58 @@ int MoleculeType::getStateValueFromName(int cIndex, string stateName) const void MoleculeType::addReactionClass(ReactionClass * r, int rPosition) { + directMembershipDecisionCache.clear(); + directMembershipDecisionCacheSafe.clear(); + unsigned int reactionIndex = static_cast(reactions.size()); this->reactions.push_back(r); this->reactionPositions.push_back(rPosition); + int partnerComponent = -1; + bool compactPartnerRegistration = + r->getCompactPartnerPoolInfo(rPosition, partnerComponent) && + partnerComponent >= 0 && partnerComponent < numOfComponents; + reactionMappingIndices.push_back(compactPartnerRegistration + ? -1 : reactionMappingCount++); + + int reactionCenterComponent = -1; + std::uint64_t contextComponentMask = 0; + unsigned int minimumContextComponents = 0; + bool indexed = r->getCompactMembershipIndexInfo( + rPosition, reactionCenterComponent, contextComponentMask, + minimumContextComponents) && + reactionCenterComponent >= 0 && + reactionCenterComponent < numOfComponents; + if (indexed) { + hasCompactEnergyMembershipIndex = true; + setCompactMembershipCandidateBit( + compactEnergyCenterCandidateBits[reactionCenterComponent], + reactionIndex); + for (int componentIndex = 0; + componentIndex < numOfComponents && componentIndex < 64; + ++componentIndex) { + if ((contextComponentMask & + std::uint64_t(1) << componentIndex) == 0) + continue; + setCompactMembershipCandidateBit( + compactEnergyContextCandidateBits[componentIndex], + reactionIndex); + unsigned int &minimum = + compactEnergyContextMinimumRequiredBits[componentIndex]; + if (minimum == 0 || minimumContextComponents < minimum) + minimum = minimumContextComponents; + } + } else { + setCompactMembershipCandidateBit( + nonCompactMembershipCandidateBits, reactionIndex); + } + if (compactPartnerRegistration) { + CompactPartnerPool *partnerPool = r->getCompactPartnerPool(); + if (partnerPool != 0) + partnerPool->registerReaction( + r, r->supportsCompactPartnerPoolUpdate()); + setCompactMembershipCandidateBit( + compactPartnerCandidateBits[partnerComponent], reactionIndex); + compactPartnerReactionIndices[partnerComponent].push_back(reactionIndex); + } //We also have to check to make sure that if the reaction is a DOR reaction, //we remember it so we can updated it @@ -507,6 +639,17 @@ void MoleculeType::addReactionClass(ReactionClass * r, int rPosition) +bool MoleculeType::canSkipIndirectMembership( + ReactionClass *firedReaction) const +{ + for (vector::const_iterator it = reactions.begin(); + it != reactions.end(); ++it) { + if (!(*it)->canSkipIndirectMembership(firedReaction)) + return false; + } + return true; +} + void MoleculeType::populateWithDefaultMolecules(int moleculeCount) { if(DEBUG) cout<< " Populating "<< this->name << " with " << moleculeCount << " molecule(s)"; @@ -559,60 +702,464 @@ void MoleculeType::prepareForSimulation() //Check each observable and see if this molecule should be counted this->addToObservables(mol); - //Check each reaction and add this molecule as a reactant if we have to + //Check each reaction and add this molecule as a reactant if we have to for(rxnIter = reactions.begin(), r=0; rxnIter != reactions.end(); rxnIter++, r++ ) { - (*rxnIter)->tryToAdd(mol, reactionPositions.at(r)); - } + if ((*rxnIter)->usesIncrementalMembership()) + (*rxnIter)->tryToAddWithIndex( + mol, reactionPositions.at(r), r); + else + (*rxnIter)->tryToAdd(mol, reactionPositions.at(r)); + } } } -void MoleculeType::updateRxnMembership(Molecule * m) +void MoleculeType::updateRxnMembership(Molecule * m, + ReactionClass *firedReaction, bool directProduct) { + const vector *cachedDecisions = 0; + const DirectMembershipDecisionCacheEntry *cachedDecisionEntry = 0; + IncrementalMembershipChange membershipChange; + bool hasMembershipChange = directProduct && firedReaction != 0 && + firedReaction->usesIncrementalMembership() && + firedReaction->getIncrementalMembershipChange(membershipChange); + bool refineMembershipChange = hasMembershipChange && + m->getMoleculeType() == membershipChange.moleculeType1; + if (refineMembershipChange && membershipChange.componentIndex1 >= 0 && + membershipChange.componentIndex1 < 64) { + /* If the weighted molecule is full immediately before or after the + * event, every accepted context dependency on the changed site crosses + * its predicate. The endpoint cache is already exact in that case. */ + std::uint64_t changedBit = std::uint64_t(1) << + membershipChange.componentIndex1; + int componentCount = m->getMoleculeType()->getNumOfComponents(); + if (componentCount <= 64) { + std::uint64_t fullMask = componentCount == 64 + ? ~std::uint64_t(0) + : ((std::uint64_t(1) << componentCount) - 1); + std::uint64_t newMask = m->getBoundComponentMask(); + std::uint64_t oldMask = membershipChange.isBoundAfter1 + ? (newMask & ~changedBit) : (newMask | changedBit); + if (newMask == fullMask || oldMask == fullMask) + refineMembershipChange = false; + } + } + bool useCompactMembershipIndex = hasMembershipChange && + m->getMoleculeType() == membershipChange.moleculeType1 && + membershipChange.componentIndex1 >= 0 && + membershipChange.componentIndex1 < 64 && + static_cast(membershipChange.componentIndex1) < + compactEnergyCenterCandidateBits.size() && + static_cast(membershipChange.componentIndex1) < + compactEnergyContextCandidateBits.size() && + hasCompactEnergyMembershipIndex; + auto refreshReactionMembership = [&](ReactionClass *rxn, + unsigned int reactionIndex) { + bool defer = this->system->isDeferringMembershipPropensityUpdates() && + rxn->supportsDeferredMembershipUpdate(); + if (defer) { + double oldA = rxn->get_a(); + bool changed = rxn->tryToAddAndReportChangeWithIndex( + m, reactionPositions.at(reactionIndex), reactionIndex); + if (changed) + this->system->deferMembershipPropensityUpdate(rxn, oldA); + return; + } + double oldA = rxn->get_a(); + if (rxn->usesIncrementalMembership()) + rxn->tryToAddWithIndex( + m, reactionPositions.at(reactionIndex), reactionIndex); + else + rxn->tryToAdd(m, reactionPositions.at(reactionIndex)); + double newA = rxn->update_a(); + this->system->update_A_tot(rxn, oldA, newA); + }; + bool useCompactPartnerPoolIndex = hasMembershipChange && directProduct && + membershipChange.moleculeType1 != membershipChange.moleculeType2 && + m->getMoleculeType() == membershipChange.moleculeType2 && + membershipChange.componentIndex2 >= 0 && + static_cast(membershipChange.componentIndex2) < + compactPartnerCandidateBits.size() && + static_cast(membershipChange.componentIndex2) < + compactPartnerReactionIndices.size() && + !compactPartnerReactionIndices[membershipChange.componentIndex2].empty(); + bool compactMembershipDecisionsComplete = useCompactMembershipIndex && + membershipChange.moleculeType1 != membershipChange.moleculeType2 && + nonCompactMembershipCandidateBits.empty(); + if (!compactMembershipDecisionsComplete && directProduct && + firedReaction != 0 && firedReaction->usesIncrementalMembership()) { + std::unordered_map::iterator safe = + directMembershipDecisionCacheSafe.find(firedReaction); + if (safe == directMembershipDecisionCacheSafe.end()) { + bool typeInvariant = true; + for (unsigned int r = 0; r < reactions.size(); ++r) { + if (!reactions[r]->membershipDecisionIsTypeInvariant()) { + typeInvariant = false; + break; + } + } + safe = directMembershipDecisionCacheSafe.emplace( + firedReaction, typeInvariant).first; + } + if (safe->second) { + std::unordered_map::iterator cached = + directMembershipDecisionCache.find(firedReaction); + if (cached == directMembershipDecisionCache.end()) { + DirectMembershipDecisionCacheEntry entry; + entry.decisions.reserve(reactions.size()); + for (unsigned int r = 0; r < reactions.size(); ++r) + entry.decisions.push_back( + reactions[r]->shouldUpdateMembership( + m, firedReaction, true)); + std::size_t affectedReactionCount = 0; + for (unsigned int r = 0; r < entry.decisions.size(); ++r) { + if (entry.decisions[r]) + ++affectedReactionCount; + } + if (affectedReactionCount * 2 < entry.decisions.size()) { + entry.reactionIndices.reserve(affectedReactionCount); + for (unsigned int r = 0; r < entry.decisions.size(); ++r) { + if (entry.decisions[r]) + entry.reactionIndices.push_back(r); + } + vector().swap(entry.decisions); + entry.useReactionIndices = true; + } + cached = directMembershipDecisionCache.emplace( + firedReaction, entry).first; + } + cachedDecisionEntry = &cached->second; + if (!cachedDecisionEntry->useReactionIndices) + cachedDecisions = &cachedDecisionEntry->decisions; + } + } + bool compactPartnerPoolChanged = false; + bool compactPartnerPoolBatchScheduled = false; + CompactPartnerPool *compactPartnerPool = 0; + int oldCompactPartnerPoolSize = 0; + const vector *partnerCandidates = 0; + if (useCompactPartnerPoolIndex) { + int partnerComponent = membershipChange.componentIndex2; + partnerCandidates = &compactPartnerCandidateBits[partnerComponent]; + const vector &partnerReactions = + compactPartnerReactionIndices[partnerComponent]; + unsigned int firstPartnerReaction = partnerReactions.front(); + compactPartnerPool = reactions[firstPartnerReaction]-> + getCompactPartnerPool(); + if (compactPartnerPool != 0) + oldCompactPartnerPoolSize = compactPartnerPool->size(); + compactPartnerPoolChanged = reactions[firstPartnerReaction]-> + refreshCompactPartnerPool( + m, reactionPositions[firstPartnerReaction]); + if (compactPartnerPoolChanged && compactPartnerPool != 0 && + this->system->isDeferringMembershipPropensityUpdates() && + compactPartnerPool->supportsBatchUpdate()) { + this->system->deferCompactPartnerPoolUpdate( + compactPartnerPool, oldCompactPartnerPoolSize, + compactPartnerPool->size()); + compactPartnerPoolBatchScheduled = true; + } + } + bool allReactionsUseCompactPartnerPool = + useCompactPartnerPoolIndex && + compactPartnerReactionIndices[membershipChange.componentIndex2].size() == + reactions.size(); + if (allReactionsUseCompactPartnerPool) { + if (compactPartnerPoolChanged) { + bool defer = this->system->isDeferringMembershipPropensityUpdates(); + if (defer && compactPartnerPoolBatchScheduled) + return; + const vector &partnerReactions = + compactPartnerReactionIndices[membershipChange.componentIndex2]; + for (vector::const_iterator it = + partnerReactions.begin(); it != partnerReactions.end(); ++it) { + ReactionClass *rxn = reactions[*it]; + if (defer) { + double oldA = rxn->get_a(); + this->system->deferMembershipPropensityUpdate(rxn, oldA); + } else { + double oldA = rxn->get_a(); + double newA = rxn->update_a(); + this->system->update_A_tot(rxn, oldA, newA); + } + } + } + return; + } + vector compactPools; + vector compactPoolOldSizes; + vector compactPoolChanged; + if (!useCompactPartnerPoolIndex) { + for (unsigned int r=0; rsupportsCompactPartnerPoolScale() || + !rxn->getCompactPartnerPoolInfo( + reactionPositions.at(r), partnerComponent)) + continue; + CompactPartnerPool *pool = rxn->getCompactPartnerPool(); + if (pool == 0 || !pool->supportsBatchUpdate()) + continue; + bool allReactionsSupportScale = true; + const vector ®istered = + pool->getRegisteredReactions(); + for (vector::const_iterator it = registered.begin(); + it != registered.end(); ++it) { + if (*it == 0 || !(*it)->supportsCompactPartnerPoolScale()) { + allReactionsSupportScale = false; + break; + } + } + if (!allReactionsSupportScale) + continue; + int poolIndex = -1; + for (unsigned int p=0; p(p); + break; + } + } + if (poolIndex >= 0) + continue; + compactPools.push_back(pool); + compactPoolOldSizes.push_back(pool->size()); + compactPoolChanged.push_back( + rxn->refreshCompactPartnerPool(m, + reactionPositions.at(r))); + } + for (unsigned int p=0; psystem->isDeferringMembershipPropensityUpdates()) + this->system->deferCompactPartnerPoolUpdate( + compactPools.at(p), compactPoolOldSizes.at(p), + compactPools.at(p)->size()); + else + this->system->updateCompactPartnerPoolBatch( + compactPools.at(p), compactPoolOldSizes.at(p), + compactPools.at(p)->size()); + } + } + + if (cachedDecisionEntry != 0 && + cachedDecisionEntry->useReactionIndices && + !useCompactMembershipIndex) { + for (vector::const_iterator it = + cachedDecisionEntry->reactionIndices.begin(); + it != cachedDecisionEntry->reactionIndices.end(); ++it) { + unsigned int r = *it; + ReactionClass *rxn = reactions.at(r); + if (useCompactPartnerPoolIndex && + compactMembershipBitIsSet(*partnerCandidates, r)) { + if (!compactPartnerPoolChanged) + continue; + bool defer = this->system->isDeferringMembershipPropensityUpdates(); + if (defer) { + if (compactPartnerPoolBatchScheduled) + continue; + double oldA = rxn->get_a(); + this->system->deferMembershipPropensityUpdate(rxn, oldA); + } else { + double oldA = rxn->get_a(); + double newA = rxn->update_a(); + this->system->update_A_tot(rxn, oldA, newA); + } + continue; + } + if (refineMembershipChange && + !rxn->shouldUpdateMembershipForChange( + m, membershipChange)) + continue; + bool handledByCompactPool = false; + int partnerComponent = -1; + if (rxn->supportsCompactPartnerPoolUpdate() && + rxn->getCompactPartnerPoolInfo( + reactionPositions.at(r), partnerComponent)) { + CompactPartnerPool *pool = rxn->getCompactPartnerPool(); + for (unsigned int p = 0; p < compactPools.size(); ++p) { + if (compactPools.at(p) == pool) { + handledByCompactPool = true; + break; + } + } + } + if (handledByCompactPool) + continue; + refreshReactionMembership(rxn, r); + } + return; + } + + if (useCompactMembershipIndex) { + int changedComponent = membershipChange.componentIndex1; + std::uint64_t changedBit = std::uint64_t(1) << changedComponent; + std::uint64_t newMask = m->getBoundComponentMask(); + std::uint64_t oldMask = membershipChange.isBoundAfter1 + ? (newMask & ~changedBit) : (newMask | changedBit); + unsigned int minimumContextBits = + compactEnergyContextMinimumRequiredBits[changedComponent]; + bool includeContext = minimumContextBits == 0 || + compactMembershipBitCount(newMask | oldMask) >= + minimumContextBits; + const vector ¢erCandidates = + compactEnergyCenterCandidateBits[changedComponent]; + const vector &contextCandidates = + compactEnergyContextCandidateBits[changedComponent]; + const std::size_t wordCount = std::max( + nonCompactMembershipCandidateBits.size(), + std::max(centerCandidates.size(), + includeContext ? contextCandidates.size() : std::size_t(0))); + + for (std::size_t wordIndex = 0; wordIndex < wordCount; ++wordIndex) { + std::uint64_t candidates = wordIndex < + nonCompactMembershipCandidateBits.size() + ? nonCompactMembershipCandidateBits[wordIndex] : 0; + std::uint64_t contextCandidateBits = 0; + if (wordIndex < centerCandidates.size()) + candidates |= centerCandidates[wordIndex]; + if (includeContext && wordIndex < contextCandidates.size()) + contextCandidateBits = contextCandidates[wordIndex]; + candidates |= contextCandidateBits; + while (candidates != 0) { + unsigned int bit = compactMembershipTrailingZeroCount(candidates); + unsigned int r = static_cast((wordIndex << 6) + bit); + candidates &= candidates - 1; + ReactionClass *rxn = reactions[r]; + std::uint64_t reactionBit = std::uint64_t(1) << bit; + bool contextCandidate = + (contextCandidateBits & reactionBit) != 0; + bool centerCandidate = wordIndex < centerCandidates.size() && + (centerCandidates[wordIndex] & reactionBit) != 0; + /* A context-only change cannot create a weighted-side mapping: + * the reaction center occupancy is unchanged. Avoid probing an + * inactive compact rule. */ + if (contextCandidate && !centerCandidate && + m->getRxnListMappingId(r) < 0) + continue; + if (!compactMembershipDecisionsComplete) { + if (cachedDecisions != 0) { + if (!(*cachedDecisions)[r]) + continue; + } else if (!rxn->shouldUpdateMembership( + m, firedReaction, directProduct)) + continue; + } + if (refineMembershipChange && + !rxn->shouldUpdateMembershipForChange( + m, membershipChange)) + continue; + bool handledByCompactPool = false; + int partnerComponent = -1; + if (rxn->supportsCompactPartnerPoolUpdate() && + rxn->getCompactPartnerPoolInfo( + reactionPositions.at(r), partnerComponent)) { + CompactPartnerPool *pool = rxn->getCompactPartnerPool(); + for (unsigned int p = 0; p < compactPools.size(); ++p) { + if (compactPools.at(p) == pool) { + handledByCompactPool = true; + break; + } + } + } + if (handledByCompactPool) + continue; + refreshReactionMembership(rxn, r); + } + } + return; + } + for( unsigned int r=0; rget_a(); - rxn->tryToAdd(m, reactionPositions.at(r)); - double newA = rxn->update_a(); - this->system->update_A_tot(rxn,oldA,newA); + if (useCompactPartnerPoolIndex && + compactMembershipBitIsSet(*partnerCandidates, r)) { + if (!compactPartnerPoolChanged) + continue; + bool defer = this->system->isDeferringMembershipPropensityUpdates(); + if (defer) { + if (compactPartnerPoolBatchScheduled) + continue; + double oldA = rxn->get_a(); + this->system->deferMembershipPropensityUpdate(rxn, oldA); + } else { + double oldA = rxn->get_a(); + double newA = rxn->update_a(); + this->system->update_A_tot(rxn, oldA, newA); + } + continue; + } + if (!compactMembershipDecisionsComplete) { + if (cachedDecisions != 0) { + if (!(*cachedDecisions)[r]) + continue; + } else if (firedReaction != 0 && + !rxn->shouldUpdateMembership( + m, firedReaction, directProduct)) + continue; + } + if (refineMembershipChange && + !rxn->shouldUpdateMembershipForChange( + m, membershipChange)) + continue; + bool handledByCompactPool = false; + int partnerComponent = -1; + if (rxn->supportsCompactPartnerPoolUpdate() && + rxn->getCompactPartnerPoolInfo( + reactionPositions.at(r), partnerComponent)) { + CompactPartnerPool *pool = rxn->getCompactPartnerPool(); + for (unsigned int p=0; pgetNumConnectedRxns(); r++) { - rxn = firedReaction->getconnectedRxn(r); - for (int pos=0; posgetNumOfReactants(); pos++) { - if (rxn->getMoleculeTypeOfReactantTemplate(pos) != this) continue; - double oldA = rxn->get_a(); - double oldAwithTotal = rxn->update_a(); + // Preserve the MoleculeType's native reaction order so the connectivity path + // mutates reactant containers in the same sequence as a full membership + // refresh, while still using the precomputed connectivity matrix. + for (unsigned int r=0; rsystem->areReactionsConnected( + firedReaction->getRxnId(), rxn->getRxnId())) { + continue; + } + if (!rxn->shouldUpdateMembership(m, firedReaction, directProduct)) + continue; + int pos = reactionPositions.at(r); + double oldA = rxn->get_a(); + double oldAwithTotal = rxn->update_a(); + if (rxn->usesIncrementalMembership()) + rxn->tryToAddWithIndex(m, pos, r); + else rxn->tryToAdd(m, pos); - double newA = rxn->update_a(); - this->system->update_A_tot(rxn,oldA,newA); - // Used for debugging to see which reaction rates changed - // upon updating molecule membership - // Arvind Rasi Subramaniam Nov 21, 2018 - if (!this->system->getTrackConnected()) continue; - if (oldAwithTotal != newA) { - this->system->getConnectedRxnFileStream() << - this->system->getGlobalEventCounter() << "\t" << - firedReaction->getName() << "\t" << - m->getMoleculeTypeName() << "\t" << - m->getUniqueID() << "\t" << - rxn->getName() << "\t" << - oldAwithTotal << "\t" << newA << endl; - } + double newA = rxn->update_a(); + this->system->update_A_tot(rxn,oldA,newA); + // Used for debugging to see which reaction rates changed + // upon updating molecule membership + // Arvind Rasi Subramaniam Nov 21, 2018 + if (!this->system->getTrackConnected()) continue; + if (oldAwithTotal != newA) { + this->system->getConnectedRxnFileStream() << + this->system->getGlobalEventCounter() << "\t" << + firedReaction->getName() << "\t" << + m->getMoleculeTypeName() << "\t" << + m->getUniqueID() << "\t" << + rxn->getName() << "\t" << + oldAwithTotal << "\t" << newA << endl; } - } + } } @@ -660,9 +1207,71 @@ void MoleculeType::removeFromObservables(Molecule *m) void MoleculeType::removeFromRxns(Molecule * m) { + vector compactPools; + vector compactPoolOldSizes; + vector compactPoolChanged; + for (unsigned int r=0; rsupportsCompactPartnerPoolScale() || + !rxn->getCompactPartnerPoolInfo( + reactionPositions.at(r), partnerComponent)) + continue; + CompactPartnerPool *pool = rxn->getCompactPartnerPool(); + if (pool == 0 || !pool->supportsBatchUpdate()) + continue; + bool allReactionsSupportScale = true; + const vector ®istered = + pool->getRegisteredReactions(); + for (vector::const_iterator it = registered.begin(); + it != registered.end(); ++it) { + if (*it == 0 || !(*it)->supportsCompactPartnerPoolScale()) { + allReactionsSupportScale = false; + break; + } + } + if (!allReactionsSupportScale) + continue; + int poolIndex = -1; + for (unsigned int p=0; p(p); + break; + } + } + if (poolIndex >= 0) + continue; + compactPools.push_back(pool); + compactPoolOldSizes.push_back(pool->size()); + rxn->remove(m, reactionPositions.at(r)); + compactPoolChanged.push_back(pool->size() != compactPoolOldSizes.back()); + } + for (unsigned int p=0; psystem->updateCompactPartnerPoolBatch( + compactPools.at(p), compactPoolOldSizes.at(p), + compactPools.at(p)->size()); + } + int r=0; for(rxnIter = reactions.begin(); rxnIter != reactions.end(); rxnIter++, r++ ) { + ReactionClass *rxn = *rxnIter; + bool handledByCompactPool = false; + int partnerComponent = -1; + if (rxn->supportsCompactPartnerPoolUpdate() && + rxn->getCompactPartnerPoolInfo( + reactionPositions.at(r), partnerComponent)) { + CompactPartnerPool *pool = rxn->getCompactPartnerPool(); + for (unsigned int p=0; pget_a(); (*rxnIter)->remove(m, reactionPositions.at(r)); double newA = (*rxnIter)->update_a(); @@ -836,5 +1445,3 @@ void MoleculeType::printDetails() const // return nfstream; // } - - diff --git a/cpp/nfsim/NFcore/reactionClass.cpp b/cpp/nfsim/NFcore/reactionClass.cpp index 6cc92f0b..124a86d6 100644 --- a/cpp/nfsim/NFcore/reactionClass.cpp +++ b/cpp/nfsim/NFcore/reactionClass.cpp @@ -14,6 +14,12 @@ ReactionClass::ReactionClass(string name, double baseRate, string baseRateParame { //cout<<"\n\ncreating reaction "<system=s; + // AST-created reactions do not pass through the legacy XML loader, so + // initialize this flag here instead of relying on a later setter. An + // indeterminate value can skip ordinary membership refreshes after a fire. + this->useConnectivity = s != nullptr && s->getConnectivityFlag(); + this->directProductMolecules = 0; + this->directProductMoleculeSetValid = false; this->tagged = false; this->useRuleMonkey = false; @@ -199,7 +205,9 @@ ReactionClass::ReactionClass(string name, double baseRate, string baseRateParame if ( this->transformationSet->usingSymmetryFactor() ) { // new general method for handling reaction center symmetry - baseRate *= this->transformationSet->getSymmetryFactor(); + // Keep the correction on the member. The constructor argument has the + // same name and scaling it would discard the correction on return. + this->baseRate *= this->transformationSet->getSymmetryFactor(); } else { // old method for handling symmetric binding and unbinding @@ -237,10 +245,21 @@ ReactionClass::ReactionClass(string name, double baseRate, string baseRateParame // check for population type reactants isPopulationType = new bool[n_reactants]; matchOncePerReactant = new bool[n_reactants]; + contextCountsPerComplex = new bool[n_reactants]; for( unsigned int i=0; i < n_reactants; ++i ) { isPopulationType[i] = reactantTemplates[i]->getMoleculeType()->isPopulationType(); matchOncePerReactant[i] = false; + contextCountsPerComplex[i] = false; + } + + if (system != 0 && system->isUsingComplex()) { + for (unsigned int i = 0; i < n_reactants; ++i) { + if (!isPopulationType[i]) { + contextCountsPerComplex[i] = + transformationSet->isPureContextReactant(i); + } + } } @@ -273,7 +292,24 @@ void ReactionClass::appendConnectedRxn(ReactionClass * rxn) { bool ReactionClass::isReactionConnected(ReactionClass * rxn) { // First check if any of the operations share MoleculeType and components with // one of the reactant templates of rxn. - return this->transformationSet->checkConnection(rxn); + if (this->transformationSet->checkConnection(rxn)) return true; + + // Full membership refresh revisits every explicit reactant template in the + // fired rule, not only templates that carry direct transformations. Treat + // any compatible explicit template as connected so the fast path preserves + // the same reachable update set. + for (unsigned int i=0; iisTemplateCompatible(allReactantTemplates[i])) return true; + } + + // Product templates can also create new compatible mappings, but avoid + // broadening synthesis rules where this would over-connect add-only paths. + if (n_reactants > 0) { + for (unsigned int i=0; iisTemplateCompatible(allProductTemplates[i])) return true; + } + } + return false; } ReactionClass::~ReactionClass() @@ -288,7 +324,9 @@ ReactionClass::~ReactionClass() delete [] mappingSet; delete [] isPopulationType; delete [] matchOncePerReactant; + delete [] contextCountsPerComplex; delete [] identicalPopCountCorrection; + delete directProductMolecules; connectedReactions.clear(); } @@ -346,6 +384,32 @@ MoleculeType *ReactionClass::getMoleculeTypeOfReactantTemplate(int pos) const { return reactantTemplates[pos]->getMoleculeType(); } +bool ReactionClass::isDirectProductMolecule(Molecule *molecule) const +{ + if (molecule == 0) + return false; + if (directProductMoleculeSetValid) + return directProductMolecules != 0 && + directProductMolecules->find(molecule) != + directProductMolecules->end(); + if (!directProductMoleculeList.empty()) + return std::find(directProductMoleculeList.begin(), + directProductMoleculeList.end(), molecule) != + directProductMoleculeList.end(); + for (unsigned int msIndex = 0; msIndex < n_mappingsets; ++msIndex) { + MappingSet *ms = mappingSet[msIndex]; + if (ms == 0) + continue; + for (unsigned int mappingIndex = 0; + mappingIndex < ms->getNumOfMappings(); ++mappingIndex) { + Mapping *mapping = ms->get(mappingIndex); + if (mapping != 0 && mapping->getMolecule() == molecule) + return true; + } + } + return false; +} + void ReactionClass::printDetails() const { cout<< name <<" (id="<rxnId<<", baseRate="<FIRE "<clear(); + directProductMoleculeSetValid = false; // First randomly pick the reactants to fire by selecting the MappingSets @@ -395,6 +463,15 @@ string ReactionClass::fire(double random_A_number, bool track) { return string(""); } + /* Compact EnergyPattern binding rules can detect an occupied endpoint + * before the generic product/membership pipeline. This preserves the + * transformation's null-event semantics while avoiding all work that + * cannot change the state. */ + if (!this->checkPreFireConditions(mappingSet)) { + ++(System::NULL_EVENT_COUNTER); + return string(""); + } + // Defensive check: a picked MappingSet can occasionally contain an unmapped entry // (null molecule) in edge cases involving internal bond reconnection/symmetry. // Treat this as a null event and skip firing to avoid dereferencing null mappings. @@ -412,6 +489,37 @@ string ReactionClass::fire(double random_A_number, bool track) { } } + /* Save the explicitly mapped molecules before transformation and membership + * refresh can recycle mapping entries. This is the source-derived direct + * endpoint identity used by incremental membership decisions. */ + if (this->useConnectivity || this->usesIncrementalMembership()) { + if (this->useConnectivity) { + if (directProductMolecules == 0) + directProductMolecules = new unordered_set(); + directProductMolecules->clear(); + directProductMoleculeSetValid = true; + } + for (unsigned int msIndex = 0; msIndex < n_mappingsets; ++msIndex) { + MappingSet *ms = mappingSet[msIndex]; + if (ms == 0) + continue; + for (unsigned int mappingIndex = 0; + mappingIndex < ms->getNumOfMappings(); ++mappingIndex) { + Mapping *mapping = ms->get(mappingIndex); + if (mapping == 0 || mapping->getMolecule() == 0) + continue; + Molecule *molecule = mapping->getMolecule(); + if (this->useConnectivity) + directProductMolecules->insert(molecule); + if (this->usesIncrementalMembership() && + std::find(directProductMoleculeList.begin(), + directProductMoleculeList.end(), molecule) == + directProductMoleculeList.end()) + directProductMoleculeList.push_back(molecule); + } + } + } + // // output something if the reaction was tagged // if(tagged) { @@ -427,8 +535,21 @@ string ReactionClass::fire(double random_A_number, bool track) { // } // Generate the set of possible products that we need to update - // (excluding new molecules, we'll get those later --Justin) - this->transformationSet->getListOfProducts(mappingSet,products,traversalLimit); + // (excluding new molecules, we'll get those later --Justin). A compact + // energy reaction can use only its explicitly mapped endpoints when every + // omitted dependency has been proven safe; all other reactions retain the + // complete bonded-neighborhood traversal. + bool directProductsPrepared = false; + if (this->canUseDirectProductList()) { + for (vector::const_iterator it = + directProductMoleculeList.begin(); + it != directProductMoleculeList.end(); ++it) + products.push_back(*it); + directProductsPrepared = true; + } else { + this->transformationSet->getListOfProducts( + mappingSet, products, traversalLimit); + } // Loop through the products (excluding added molecules) and remove from observables if (this->onTheFlyObservables) { @@ -451,7 +572,9 @@ string ReactionClass::fire(double random_A_number, bool track) { c = mappingSet[k]->get(0)->getMolecule()->getComplex(); for(int i=0; igetNumOfSpeciesObs(); i++) { matches = system->getSpeciesObs(i)->isObservable(c); - system->getSpeciesObs(i)->straightSubtract(matches); + if (matches > 0) { + system->getSpeciesObs(i)->subtract(matches); + } } } } @@ -469,7 +592,9 @@ string ReactionClass::fire(double random_A_number, bool track) { c = addmol->getComplex(); for (int i=0; i < system->getNumOfSpeciesObs(); i++) { matches = system->getSpeciesObs(i)->isObservable(c); - system->getSpeciesObs(i)->straightSubtract(matches); + if (matches > 0) { + system->getSpeciesObs(i)->subtract(matches); + } } } } @@ -530,7 +655,9 @@ string ReactionClass::fire(double random_A_number, bool track) { matches = 0; for ( int i=0; i < system->getNumOfSpeciesObs(); i++ ) { matches = system->getSpeciesObs(i)->isObservable(c); - system->getSpeciesObs(i)->straightAdd(matches); + if (matches > 0) { + system->getSpeciesObs(i)->add(matches); + } } } @@ -543,6 +670,11 @@ string ReactionClass::fire(double random_A_number, bool track) { // also, gather a list of typeII dependencies that will require updating typeII_products.clear(); std::unordered_set typeIIProductSet; + bool deferMembershipPropensityUpdates = + directProductsPrepared && this->usesIncrementalMembership() && + !useConnectivity; + if (deferMembershipPropensityUpdates) + this->system->beginDeferredMembershipPropensityUpdates(); for ( molIter = products.begin(); molIter != products.end(); molIter++ ) { Molecule * mol = *molIter; MoleculeType * mt = mol->getMoleculeType(); @@ -558,9 +690,14 @@ string ReactionClass::fire(double random_A_number, bool track) { //Update this molcule's reaction membership // NOTE: as a side-effect, DORreactions that depend on molecule-scoped local functions // (typeI relationship) will be updated as long as UTL is set appropriately. - if ( mol->isAlive() ) - mol->updateRxnMembership(this, useConnectivity); + if ( mol->isAlive() ) { + bool directProduct = !this->usesIncrementalMembership() || + this->isDirectProductMolecule(mol); + mol->updateRxnMembership(this, useConnectivity, directProduct); + } } + if (deferMembershipPropensityUpdates) + this->system->endDeferredMembershipPropensityUpdates(); // update complex-scoped local functions for typeII dependencies // NOTE: as a side-effect, dependent DOR reactions (via typeI molecule dependencies) will be updated diff --git a/cpp/nfsim/NFcore/reactionSelector/directSelector.cpp b/cpp/nfsim/NFcore/reactionSelector/directSelector.cpp index e0ad9827..7d0966b0 100644 --- a/cpp/nfsim/NFcore/reactionSelector/directSelector.cpp +++ b/cpp/nfsim/NFcore/reactionSelector/directSelector.cpp @@ -9,9 +9,94 @@ #include "reactionSelector.hh" +#if defined(_MSC_VER) +#include +#endif + using namespace std; using namespace NFcore; +namespace { + +unsigned int directSelectorTrailingZeroCount(std::uint64_t value) +{ +#if defined(_MSC_VER) + unsigned long bit = 0; + _BitScanForward64(&bit, value); + return static_cast(bit); +#else + return static_cast(__builtin_ctzll(value)); +#endif +} + +/* Active bits normally remain unchanged during a compact batch. Avoiding a + * redundant store matters because the sparse scan reads these words directly. */ +inline void directSelectorUpdateActiveBit( + std::uint64_t &word, std::uint64_t bit, bool wasActive, bool isActive) +{ + if (wasActive == isActive) + return; + if (isActive) + word |= bit; + else + word &= ~bit; +} + +} + + +double ReactionSelector::updateBatch(vector &rxns) +{ + for (vector::const_iterator it = rxns.begin(); + it != rxns.end(); ++it) { + ReactionClass *r = *it; + if (r == 0) + continue; + double oldA = r->get_a(); + double newA = r->update_a(); + update(r, oldA, newA); + } + return getAtot(); +} + + +double ReactionSelector::updateBatch( + vector &rxns, const vector &oldAs) +{ + for (std::size_t i = 0; i < rxns.size(); ++i) { + ReactionClass *r = rxns[i]; + if (r == 0) + continue; + double oldA = i < oldAs.size() ? oldAs[i] : r->get_a(); + double newA = r->update_a(); + update(r, oldA, newA); + } + return getAtot(); +} + + +double ReactionSelector::updateCompactPartnerPoolBatch( + const vector &rxns, + int oldPoolSize, int newPoolSize, + unsigned long long deferredGeneration) +{ + if (oldPoolSize == newPoolSize) + return getAtot(); + for (vector::const_iterator it = rxns.begin(); + it != rxns.end(); ++it) { + ReactionClass *r = *it; + if (r == 0 || (deferredGeneration != 0 && + r->hasDeferredMembershipUpdate(deferredGeneration))) + continue; + double coefficient = r->getCompactPartnerPoolCoefficient(); + double oldA = coefficient == 0.0 ? r->get_a() + : coefficient * static_cast(oldPoolSize); + double newA = r->update_a_for_compact_partner_pool(newPoolSize); + update(r, oldA, newA); + } + return getAtot(); +} + @@ -20,10 +105,78 @@ DirectSelector::DirectSelector(vector &rxns, System *sys) : { this->Atot = 0; this->n_reactions = rxns.size(); + this->reactionIndexMode = -1; this->reactionClassList = new ReactionClass *[n_reactions]; + this->selectionBlockSize = 16; + this->selectionBlockPropensities.assign( + (static_cast(n_reactions) + selectionBlockSize - 1) / + selectionBlockSize, 0.0); + this->reactionPropensities.assign(static_cast(n_reactions), 0.0); + this->sparseSelectionSafe = n_reactions > 0; + this->compactPoolGroupByReaction.assign( + static_cast(n_reactions), -1); + this->compactPoolCoefficients.assign( + static_cast(n_reactions), 0.0); for(int r=0; rget_a(); + double propensity = reactionClassList[r]->get_a(); + reactionPropensities[static_cast(r)] = propensity; + Atot += propensity; + selectionBlockPropensities[static_cast(r) / + selectionBlockSize] += propensity; + if (!reactionClassList[r]->supportsSparseSelection()) + sparseSelectionSafe = false; + } + if (sparseSelectionSafe) { + activeReactionBits.assign( + (static_cast(n_reactions) + 63) / 64, 0); + for (int r=0; r(r)] != 0.0) + activeReactionBits[static_cast(r) >> 6] |= + (std::uint64_t(1) << (r & 63)); + } + for (int r = 0; r < n_reactions; ++r) { + ReactionClass *reaction = reactionClassList[r]; + if (!reaction->supportsCompactPartnerPoolScale()) + continue; + CompactPartnerPool *pool = reaction->getCompactPartnerPool(); + if (pool == 0 || !pool->supportsBatchUpdate()) + continue; + bool everyRegisteredReactionSupportsScale = true; + const vector ®istered = + pool->getRegisteredReactions(); + for (vector::const_iterator it = registered.begin(); + it != registered.end(); ++it) { + if (*it == 0 || !(*it)->supportsCompactPartnerPoolScale()) { + everyRegisteredReactionSupportsScale = false; + break; + } + } + if (!everyRegisteredReactionSupportsScale) + continue; + int groupIndex = findCompactPoolGroup(pool); + if (groupIndex < 0) { + CompactPoolSelectionGroup group; + group.pool = pool; + group.poolSize = pool->size(); + group.blockCoefficients.assign( + selectionBlockPropensities.size(), 0.0); + compactPoolSelectionGroups.push_back(group); + groupIndex = static_cast(compactPoolSelectionGroups.size() - 1); + } + std::size_t reactionIndex = static_cast(r); + CompactPoolSelectionGroup &group = + compactPoolSelectionGroups[static_cast(groupIndex)]; + compactPoolGroupByReaction[reactionIndex] = groupIndex; + group.reactionIndices.push_back(r); + double coefficient = reaction->getCompactPartnerPoolCoefficient(); + compactPoolCoefficients[reactionIndex] = coefficient; + group.totalCoefficient += coefficient; + group.blockCoefficients[reactionIndex / selectionBlockSize] += + coefficient; + } + } else { + vector().swap(reactionPropensities); } } @@ -34,13 +187,127 @@ DirectSelector::~DirectSelector() Atot = 0; n_reactions = 0; delete [] reactionClassList; + sparseSelectionSafe = false; + activeReactionBits.clear(); + reactionPropensities.clear(); + compactPoolGroupByReaction.clear(); + compactPoolCoefficients.clear(); + compactPoolSelectionGroups.clear(); +} + + +int DirectSelector::findCompactPoolGroup(CompactPartnerPool *pool) const +{ + if (pool == 0) + return -1; + for (std::size_t i = 0; i < compactPoolSelectionGroups.size(); ++i) { + if (compactPoolSelectionGroups[i].pool == pool) + return static_cast(i); + } + return -1; +} + + +void DirectSelector::updateCompactPoolActiveBits( + const CompactPoolSelectionGroup &group, bool active) +{ + if (!sparseSelectionSafe) + return; + for (vector::const_iterator it = group.reactionIndices.begin(); + it != group.reactionIndices.end(); ++it) { + int reaction = *it; + bool isActive = active && + compactPoolCoefficients[static_cast(reaction)] != 0.0; + std::uint64_t &word = + activeReactionBits[static_cast(reaction) >> 6]; + std::uint64_t bit = std::uint64_t(1) << (reaction & 63); + if (isActive) + word |= bit; + else + word &= ~bit; + } +} + + +void DirectSelector::applyCompactPoolGroupSize( + int groupIndex, int newPoolSize) +{ + CompactPoolSelectionGroup &group = + compactPoolSelectionGroups[static_cast(groupIndex)]; + if (group.poolSize == newPoolSize) + return; + double delta = static_cast(newPoolSize - group.poolSize); + Atot += group.totalCoefficient * delta; + for (std::size_t block = 0; block < group.blockCoefficients.size(); ++block) + selectionBlockPropensities[block] += + group.blockCoefficients[block] * delta; + bool crossedZero = (group.poolSize == 0) != (newPoolSize == 0); + group.poolSize = newPoolSize; + if (crossedZero) + updateCompactPoolActiveBits(group, newPoolSize != 0); +} + + +void DirectSelector::synchronizeCompactPoolGroup(int groupIndex) +{ + CompactPoolSelectionGroup &group = + compactPoolSelectionGroups[static_cast(groupIndex)]; + if (group.pool != 0) + applyCompactPoolGroupSize(groupIndex, group.pool->size()); } + +double DirectSelector::getSelectionPropensity(std::size_t reaction) const +{ + if (reaction < compactPoolGroupByReaction.size()) { + int groupIndex = compactPoolGroupByReaction[reaction]; + if (groupIndex >= 0) + return compactPoolCoefficients[reaction] * + static_cast(compactPoolSelectionGroups[ + static_cast(groupIndex)].poolSize); + } + return reactionPropensities[reaction]; +} + + double DirectSelector::refactorPropensities() { Atot = 0; + std::fill(selectionBlockPropensities.begin(), + selectionBlockPropensities.end(), 0.0); + if (sparseSelectionSafe) + std::fill(activeReactionBits.begin(), activeReactionBits.end(), 0); + for (vector::iterator it = + compactPoolSelectionGroups.begin(); + it != compactPoolSelectionGroups.end(); ++it) { + it->poolSize = it->pool == 0 ? 0 : it->pool->size(); + it->totalCoefficient = 0.0; + std::fill(it->blockCoefficients.begin(), + it->blockCoefficients.end(), 0.0); + } for(int r=0; rupdate_a(); + double propensity = reactionClassList[r]->update_a(); + if (sparseSelectionSafe) { + reactionPropensities[static_cast(r)] = propensity; + int groupIndex = compactPoolGroupByReaction[ + static_cast(r)]; + if (groupIndex >= 0) { + double coefficient = reactionClassList[r]-> + getCompactPartnerPoolCoefficient(); + compactPoolCoefficients[static_cast(r)] = coefficient; + CompactPoolSelectionGroup &group = compactPoolSelectionGroups[ + static_cast(groupIndex)]; + group.totalCoefficient += coefficient; + group.blockCoefficients[static_cast(r) / + selectionBlockSize] += coefficient; + } + } + Atot += propensity; + selectionBlockPropensities[static_cast(r) / + selectionBlockSize] += propensity; + if (sparseSelectionSafe && propensity != 0.0) + activeReactionBits[static_cast(r) >> 6] |= + (std::uint64_t(1) << (r & 63)); } return Atot; } @@ -48,8 +315,169 @@ double DirectSelector::refactorPropensities() double DirectSelector::update(ReactionClass *r,double oldA, double newA) { + if (r == 0) + return Atot; + if (reactionIndexMode < 0) { + reactionIndexMode = 1; + for (int i = 0; i < n_reactions; ++i) { + if (reactionClassList[i]->getRxnId() != i) { + reactionIndexMode = 0; + break; + } + } + } + int reaction = r->getRxnId(); + if (reaction < 0 || reaction >= n_reactions || + reactionClassList[reaction] != r || reactionIndexMode == 0) { + /* System::prepareForSimulation() assigns the global reaction id before + * runtime updates. Retain a cold fallback for direct test callers. */ + for (reaction = 0; reaction < n_reactions; ++reaction) { + if (reactionClassList[reaction] == r) + break; + } + } + if (reaction >= 0 && reaction < n_reactions) { + std::size_t reactionIndex = static_cast(reaction); + int groupIndex = reactionIndex < compactPoolGroupByReaction.size() + ? compactPoolGroupByReaction[reactionIndex] : -1; + if (groupIndex >= 0) { + synchronizeCompactPoolGroup(groupIndex); + CompactPoolSelectionGroup &group = compactPoolSelectionGroups[ + static_cast(groupIndex)]; + double oldCoefficient = compactPoolCoefficients[reactionIndex]; + double oldEffective = oldCoefficient * + static_cast(group.poolSize); + double newCoefficient = r->getCompactPartnerPoolCoefficient(); + Atot -= oldEffective; + Atot += newA; + std::size_t block = reactionIndex / selectionBlockSize; + selectionBlockPropensities[block] -= oldEffective; + selectionBlockPropensities[block] += newA; + group.totalCoefficient += newCoefficient - oldCoefficient; + group.blockCoefficients[block] += newCoefficient - oldCoefficient; + compactPoolCoefficients[reactionIndex] = newCoefficient; + if (sparseSelectionSafe) { + reactionPropensities[reactionIndex] = newA; + std::uint64_t &word = activeReactionBits[reactionIndex >> 6]; + std::uint64_t bit = std::uint64_t(1) << (reaction & 63); + directSelectorUpdateActiveBit(word, bit, + oldEffective != 0.0, newA != 0.0); + } + return Atot; + } + } Atot-=oldA; Atot+=newA; + if (reaction >= 0 && reaction < n_reactions) { + std::size_t block = static_cast(reaction) / + selectionBlockSize; + selectionBlockPropensities[block] -= oldA; + selectionBlockPropensities[block] += newA; + if (sparseSelectionSafe) + reactionPropensities[static_cast(reaction)] = newA; + } + if (sparseSelectionSafe && reaction >= 0 && reaction < n_reactions) { + std::uint64_t &word = + activeReactionBits[static_cast(reaction) >> 6]; + std::uint64_t bit = std::uint64_t(1) << (reaction & 63); + directSelectorUpdateActiveBit(word, bit, + oldA != 0.0, newA != 0.0); + } + return Atot; +} + + +double DirectSelector::updateBatch(vector &rxns) +{ + if (reactionIndexMode < 0) { + reactionIndexMode = 1; + for (int i = 0; i < n_reactions; ++i) { + if (reactionClassList[i]->getRxnId() != i) { + reactionIndexMode = 0; + break; + } + } + } + for (vector::const_iterator it = rxns.begin(); + it != rxns.end(); ++it) { + ReactionClass *r = *it; + if (r == 0) + continue; + int reaction = r->getRxnId(); + bool indexedReaction = sparseSelectionSafe && + reaction >= 0 && reaction < n_reactions && + reactionClassList[reaction] == r; + double oldA = indexedReaction + ? reactionPropensities[static_cast(reaction)] + : r->get_a(); + double newA = r->update_a(); + update(r, oldA, newA); + } + return Atot; +} + + +double DirectSelector::updateBatch( + vector &rxns, const vector &oldAs) +{ + if (reactionIndexMode < 0) { + reactionIndexMode = 1; + for (int i = 0; i < n_reactions; ++i) { + if (reactionClassList[i]->getRxnId() != i) { + reactionIndexMode = 0; + break; + } + } + } + for (std::size_t i = 0; i < rxns.size(); ++i) { + ReactionClass *r = rxns[i]; + if (r == 0) + continue; + double oldA = i < oldAs.size() ? oldAs[i] : r->get_a(); + double newA = r->update_a(); + update(r, oldA, newA); + } + return Atot; +} + + +double DirectSelector::updateCompactPartnerPoolBatch( + const vector &rxns, + int oldPoolSize, int newPoolSize, + unsigned long long deferredGeneration) +{ + if (oldPoolSize == newPoolSize) + return Atot; + CompactPartnerPool *pool = 0; + for (vector::const_iterator it = rxns.begin(); + it != rxns.end(); ++it) { + if (*it != 0) { + pool = (*it)->getCompactPartnerPool(); + if (pool != 0) + break; + } + } + int groupIndex = findCompactPoolGroup(pool); + if (groupIndex >= 0) { + /* Deferred batches update changed reactions first, synchronizing the + * group to the post-event pool size. Ordinary pool-only changes use the + * exact lazy scale here. */ + (void)deferredGeneration; + applyCompactPoolGroupSize(groupIndex, newPoolSize); + return Atot; + } + for (vector::const_iterator it = rxns.begin(); + it != rxns.end(); ++it) { + ReactionClass *r = *it; + if (r == 0 || (deferredGeneration != 0 && + r->hasDeferredMembershipUpdate(deferredGeneration))) + continue; + double coefficient = r->getCompactPartnerPoolCoefficient(); + double oldA = coefficient == 0.0 ? r->get_a() + : coefficient * static_cast(oldPoolSize); + double newA = r->update_a_for_compact_partner_pool(newPoolSize); + update(r, oldA, newA); + } return Atot; } @@ -60,26 +488,99 @@ double DirectSelector::getNextReactionClass(ReactionClass *&rc) double randNum = sys_->getRNG().random(Atot); double a_sum=0, last_a_sum=0; + /* Compact EnergyPattern reactions can omit zero-rate entries while keeping + * reaction order. Prefix blocks bound the scan and retain a dense fallback + * for reaction classes that cannot prove this invariant. */ + if (sparseSelectionSafe && selectionBlockPropensities.size() > 1) { + for (std::size_t block = 0; + block < selectionBlockPropensities.size(); ++block) { + double blockPropensity = selectionBlockPropensities[block]; + if (randNum <= a_sum + blockPropensity) { + std::size_t first = block * selectionBlockSize; + std::size_t last = std::min( + first + selectionBlockSize, + static_cast(n_reactions)); + if (selectionBlockSize <= 64 && + (first >> 6) == ((last - 1) >> 6)) { + unsigned int bitOffset = + static_cast(first & 63); + std::uint64_t active = activeReactionBits[first >> 6] >> + bitOffset; + unsigned int bitCount = + static_cast(last - first); + if (bitCount < 64) + active &= (std::uint64_t(1) << bitCount) - 1; + while (active != 0) { + unsigned int bit = directSelectorTrailingZeroCount(active); + std::size_t r = first + bit; + active &= active - 1; + a_sum += getSelectionPropensity(r); + if (randNum <= a_sum) { + rc = reactionClassList[r]; + return (randNum-last_a_sum); + } + last_a_sum = a_sum; + } + } else { + for (std::size_t r = first; r < last; ++r) { + if ((activeReactionBits[r >> 6] & + (std::uint64_t(1) << (r & 63))) == 0) + continue; + a_sum += getSelectionPropensity(r); + if (randNum <= a_sum) { + rc = reactionClassList[r]; + return (randNum-last_a_sum); + } + last_a_sum = a_sum; + } + } + /* Re-entering the next block after an exact scan handles the rare + * one-ulp difference between block and scalar prefix sums. */ + last_a_sum = a_sum; + continue; + } + a_sum += blockPropensity; + last_a_sum = a_sum; + } + this->refactorPropensities(); + return getNextReactionClass(rc); + } //WARNING - DO NOT USE THE DEFAULT C++ RANDOM NUMBER GENERATOR FOR THIS STEP // - IT INTRODUCES SMALL NUMERICAL ERRORS CAUSING THE ORDER OF RXNS TO // AFFECT SIMULATION RESULTS - for(int r=0; rget_a(); - if(randNum <= a_sum) - { - rc = reactionClassList[r]; - return (randNum-last_a_sum); + if (sparseSelectionSafe) { + for(std::size_t wordIndex=0; wordIndex((wordIndex << 6) + bit); + active &= active - 1; + a_sum += getSelectionPropensity(r); + if(randNum <= a_sum) + { + rc = reactionClassList[r]; + return (randNum-last_a_sum); + } + last_a_sum = a_sum; + } + } + } else { + for(int r=0; rget_a(); + if(randNum <= a_sum) + { + rc = reactionClassList[r]; + return (randNum-last_a_sum); + } + last_a_sum = a_sum; } - last_a_sum = a_sum; } this->refactorPropensities(); return getNextReactionClass(rc); - //cerr<<"Error in Direct Reaction Selector: randNum exceeds a_sum!!!"< &rxns); + virtual double updateBatch(vector &rxns, + const vector &oldAs); + virtual double updateCompactPartnerPoolBatch( + const vector &rxns, + int oldPoolSize, int newPoolSize, + unsigned long long deferredGeneration); virtual double getNextReactionClass(ReactionClass *&rc) = 0; virtual double getAtot() = 0; @@ -56,16 +64,57 @@ namespace NFcore virtual double update(ReactionClass *r,double oldA, double newA); + virtual double updateBatch(vector &rxns); + virtual double updateBatch(vector &rxns, + const vector &oldAs); + virtual double updateCompactPartnerPoolBatch( + const vector &rxns, + int oldPoolSize, int newPoolSize, + unsigned long long deferredGeneration); virtual double getNextReactionClass(ReactionClass *&rc); virtual double getAtot(); protected: + struct CompactPoolSelectionGroup { + CompactPoolSelectionGroup() : pool(0), poolSize(0), + totalCoefficient(0.0), blockCoefficients(), reactionIndices() {} + CompactPartnerPool *pool; + int poolSize; + double totalCoefficient; + vector blockCoefficients; + vector reactionIndices; + }; + + int findCompactPoolGroup(CompactPartnerPool *pool) const; + void applyCompactPoolGroupSize(int groupIndex, int newPoolSize); + void synchronizeCompactPoolGroup(int groupIndex); + void updateCompactPoolActiveBits( + const CompactPoolSelectionGroup &group, + bool active); + double getSelectionPropensity(std::size_t reaction) const; + double Atot; int n_reactions; ReactionClass ** reactionClassList; - - }; + bool sparseSelectionSafe; + /* -1 until the first update; 1 when prepared reaction IDs match + * selector positions, 0 for direct-selector compatibility callers. */ + int reactionIndexMode; + vector activeReactionBits; + unsigned int selectionBlockSize; + vector selectionBlockPropensities; + /* Sparse selectors read propensities frequently. Keep a contiguous + * mirror; grouped compact rules derive selection values from their + * coefficient and shared pool size. */ + vector reactionPropensities; + /* For factorized compact forward rules, retain weighted-side + * coefficients while shared partner-pool size is selector state. */ + vector compactPoolGroupByReaction; + vector compactPoolCoefficients; + vector compactPoolSelectionGroups; + + }; class LogClassSelector : public ReactionSelector { diff --git a/cpp/nfsim/NFcore/system.cpp b/cpp/nfsim/NFcore/system.cpp index 3fac5dbd..b2d8f6f3 100644 --- a/cpp/nfsim/NFcore/system.cpp +++ b/cpp/nfsim/NFcore/system.cpp @@ -20,9 +20,22 @@ using namespace NFcore; int System::NULL_EVENT_COUNTER = 0; +namespace { + +void validateSystemName(const string &name) +{ + if (name.find("..") != string::npos || name.find("/") != string::npos || + name.find("\\") != string::npos || name.find(":") != string::npos) { + throw runtime_error("Path traversal detected in System name."); + } +} + +} + System::System(string name) { + validateSystemName(name); this->name = name; this->a_tot = 0; current_time = 0; @@ -65,6 +78,7 @@ System::System(string name) System::System(string name, bool useComplex) { + validateSystemName(name); this->name = name; this->a_tot = 0; current_time = 0; @@ -108,6 +122,7 @@ System::System(string name, bool useComplex) System::System(string name, bool useComplex, int globalMoleculeLimit) { + validateSystemName(name); this->name = name; this->a_tot = 0; current_time = 0; @@ -291,6 +306,12 @@ void System::setUsingComplex(bool val) } } +void System::setCurrentTime(double time) +{ + current_time = time; + invalidateStepToCache(); +} + void System::setOutputToBinary() { this->useBinaryOutput = true; @@ -665,6 +686,7 @@ int System::getMolObsCount(int moleculeTypeIndex, int observableIndex) const //observables. void System::prepareForSimulation() { + invalidateStepToCache(); if (selector != 0) { delete selector; selector = 0; @@ -934,6 +956,77 @@ void System::update_A_tot(ReactionClass *r, double old_a, double new_a) //a_tot+=new_a; } +void System::beginDeferredMembershipPropensityUpdates() +{ + deferredMembershipReactions.clear(); + deferredMembershipOldPropensities.clear(); + deferredCompactPartnerPoolUpdates.clear(); + ++deferredMembershipUpdateGeneration; + deferringMembershipPropensityUpdates = true; +} + +void System::deferMembershipPropensityUpdate(ReactionClass *r, double oldA) +{ + if (r != 0 && r->markDeferredMembershipUpdate( + deferredMembershipUpdateGeneration)) { + deferredMembershipReactions.push_back(r); + deferredMembershipOldPropensities.push_back(oldA); + } +} + +void System::deferCompactPartnerPoolUpdate( + CompactPartnerPool *pool, int oldPoolSize, int newPoolSize) +{ + if (!deferringMembershipPropensityUpdates || pool == 0 || + oldPoolSize == newPoolSize || !pool->supportsBatchUpdate()) + return; + for (vector::iterator it = + deferredCompactPartnerPoolUpdates.begin(); + it != deferredCompactPartnerPoolUpdates.end(); ++it) { + if (it->pool == pool) { + it->newPoolSize = newPoolSize; + return; + } + } + DeferredCompactPartnerPoolUpdate update; + update.pool = pool; + update.oldPoolSize = oldPoolSize; + update.newPoolSize = newPoolSize; + deferredCompactPartnerPoolUpdates.push_back(update); +} + +void System::endDeferredMembershipPropensityUpdates() +{ + if (!deferringMembershipPropensityUpdates) + return; + /* Reset the guard before selector work so nested updates follow the normal + * immediate path. */ + deferringMembershipPropensityUpdates = false; + if (selector != 0) { + a_tot = selector->updateBatch( + deferredMembershipReactions, deferredMembershipOldPropensities); + for (vector::const_iterator it = + deferredCompactPartnerPoolUpdates.begin(); + it != deferredCompactPartnerPoolUpdates.end(); ++it) { + a_tot = selector->updateCompactPartnerPoolBatch( + it->pool->getRegisteredReactions(), it->oldPoolSize, + it->newPoolSize, deferredMembershipUpdateGeneration); + } + } + deferredMembershipReactions.clear(); + deferredMembershipOldPropensities.clear(); + deferredCompactPartnerPoolUpdates.clear(); +} + +void System::updateCompactPartnerPoolBatch( + CompactPartnerPool *pool, int oldPoolSize, int newPoolSize) +{ + if (selector == 0 || pool == 0 || oldPoolSize == newPoolSize) + return; + a_tot = selector->updateCompactPartnerPoolBatch( + pool->getRegisteredReactions(), oldPoolSize, newPoolSize, 0); +} + double System::recompute_A_tot() { @@ -1005,6 +1098,7 @@ double System::sim(double duration, long int sampleTimes) /* main simulation loop */ double System::sim(double duration, long int sampleTimes, bool verbose) { + invalidateStepToCache(); System::NULL_EVENT_COUNTER=0; cout.setf(ios::scientific); cout<<"simulating system for: "< ATOT_TOLERANCE) { - delta_t = -log(rng_.random_closed()) / a_tot; - } else { - // Otherwise, we can't react for the rest of this step - delta_t = 0; - current_time = stoppingTime; - cout << "Total propensity is zero, no further rxns can fire in this step." << endl; - break; + if(!pendingStepEventValid) { + // Preserve the pending waiting-time draw across output boundaries so + // repeated stepTo() calls consume the same RNG stream as sim(). + if(a_tot > ATOT_TOLERANCE) { + pendingStepEventTime = + current_time + (-log(rng_.random_open()) / a_tot); + pendingStepEventValid = true; + } else { + current_time = stoppingTime; + cout << "Total propensity is zero, no further rxns can fire in this step." << endl; + break; + } } // Check if we've reached stopping time - if((current_time + delta_t) >= stoppingTime) { + if(pendingStepEventTime >= stoppingTime && !includeEndpointEvent) { break; } // Select and fire the next reaction double randElement = getNextRxn(); - if(nextReaction == NULL) break; + if(nextReaction == NULL) { + invalidateStepToCache(); + break; + } - current_time += delta_t; + current_time = pendingStepEventTime; globalEventCounter++; nextReaction->fire(randElement); + invalidateStepToCache(); // Replenish fixed species after reaction fires replenishFixedSpecies(); @@ -1261,20 +1365,25 @@ double System::stepTo(double stoppingTime) } recompute_A_tot(); } + + if (includeEndpointEvent && current_time >= stoppingTime) + break; } + current_time = stoppingTime; return current_time; } void System::singleStep() { + invalidateStepToCache(); cout<<" -System is at time: "<current_time<ATOT_TOLERANCE) delta_t = -log(rng_.random_closed()) / a_tot; + if(a_tot>ATOT_TOLERANCE) delta_t = -log(rng_.random_open()) / a_tot; else { //Otherwise, we can't react for the rest of this step @@ -1304,9 +1413,11 @@ void System::singleStep() void System::equilibrate(double duration) { + invalidateStepToCache(); double startTime = current_time; - stepTo(duration); + stepTo(startTime + duration); current_time = startTime; + invalidateStepToCache(); } void System::equilibrate(double duration, int statusReports) @@ -1377,6 +1488,7 @@ void System::replenishFixedSpecies() { } if (updated) { + invalidateStepToCache(); recompute_A_tot(); } } @@ -1395,11 +1507,13 @@ void System::resetConcentrations() { cerr << "Error: no saved concentrations to reset to." << endl; return; } + invalidateStepToCache(); savedSnapshot->restore(this); cout << "Reset concentrations to saved state." << endl; } void System::addConcentration(const string& speciesPattern, int count) { + invalidateStepToCache(); // Try to find the molecule type name (substring before parenthesis or entire string) string molTypeName = speciesPattern; size_t parenPos = speciesPattern.find('('); @@ -1443,10 +1557,12 @@ void System::recalculateAllObservables() { } void System::updateAllReactionPropensities() { + invalidateStepToCache(); recompute_A_tot(); } void System::destroyAllMolecules() { + invalidateStepToCache(); // For each MoleculeType, remove all molecules for (auto molTypeIter = allMoleculeTypes.begin(); molTypeIter != allMoleculeTypes.end(); ++molTypeIter) { (*molTypeIter)->removeAllMolecules(); @@ -2175,6 +2291,7 @@ void System::setParameter(const string& name, double value) { this->paramMap[name]=value; } void System::updateSystemWithNewParameters() { + invalidateStepToCache(); //Update all global functions for(unsigned int i=0; iglobalFunctions.size(); i++) { diff --git a/cpp/nfsim/NFfunction/NFfunction.hh b/cpp/nfsim/NFfunction/NFfunction.hh index 9a88cad7..c0194f0c 100644 --- a/cpp/nfsim/NFfunction/NFfunction.hh +++ b/cpp/nfsim/NFfunction/NFfunction.hh @@ -441,6 +441,7 @@ namespace NFcore { int getNumOfArgs() const; string getArgName(int aIndex) const; + const string& getOriginalExpression() const { return originalExpression; } void addTypeIMoleculeDependency(MoleculeType *mt); bool hasTimeDependentLocalFunction() const; diff --git a/cpp/nfsim/NFfunction/nfsim_funcparser.h b/cpp/nfsim/NFfunction/nfsim_funcparser.h index ee585b25..86dca4cf 100644 --- a/cpp/nfsim/NFfunction/nfsim_funcparser.h +++ b/cpp/nfsim/NFfunction/nfsim_funcparser.h @@ -135,6 +135,28 @@ inline std::string remap_expression(const std::string& expr, return result; } +// BNG2/NFsim expressions commonly spell logical conjunction and disjunction +// as && and ||. ExprTk deliberately reserves those characters and accepts +// the word operators instead. Normalize only the legacy operators here; +// callers still observe the original expression through GetExpr(). +inline std::string normalize_legacy_logical_operators(const std::string& expr) +{ + std::string result; + result.reserve(expr.size() + 8); + for (std::size_t i = 0; i < expr.size();) { + if (expr.compare(i, 2, "&&") == 0) { + result += " and "; + i += 2; + } else if (expr.compare(i, 2, "||") == 0) { + result += " or "; + i += 2; + } else { + result.push_back(expr[i++]); + } + } + return result; +} + } // namespace detail // ─── mu::Parser — ExprTk-based drop-in replacement ────────────────────────── @@ -210,8 +232,10 @@ class Parser { // ─── SetExpr: compile the expression ──────────────────────────────── void SetExpr(const std::string& expr) { original_expr_string_ = expr; + const std::string normalized_expr = + detail::normalize_legacy_logical_operators(expr); // Remap underscore-prefixed identifiers before ExprTk compilation - expr_string_ = detail::remap_expression(expr, underscore_names_); + expr_string_ = detail::remap_expression(normalized_expr, underscore_names_); compile(); } diff --git a/cpp/nfsim/NFinput/NFinput.cpp b/cpp/nfsim/NFinput/NFinput.cpp index 78cc2b3b..5c51cbd9 100644 --- a/cpp/nfsim/NFinput/NFinput.cpp +++ b/cpp/nfsim/NFinput/NFinput.cpp @@ -1,6 +1,7 @@ #include "NFinput.hh" #include "NFinput_energy.hh" #include "../NFcore/compartment.hh" +#include "parser/BNGAstVisitor.hpp" #include @@ -1281,6 +1282,57 @@ string NFinput::initStartSpecies( namespace { +struct LegacyCompositeDOR2 { + string functionName1; + string functionName2; + string argument1; + string argument2; +}; + +bool parseLegacyCompositeDOR2( + CompositeFunction *composite, + System *system, + LegacyCompositeDOR2 &result) +{ + if (composite == NULL || system == NULL || composite->getNumOfArgs() != 2) { + return false; + } + + bng::ast::Expression expression; + try { + expression = bng::parser::parseExpression(composite->getOriginalExpression()); + } catch (...) { + return false; + } + if (expression.kind() != bng::ast::ExpressionKind::Binary || + expression.name() != "*" || expression.args().size() != 2) { + return false; + } + + const auto parseOperand = [&](const bng::ast::Expression &operand, + string &functionName, string &argument) { + using bng::ast::ExpressionKind; + if (operand.kind() != ExpressionKind::Function && + operand.kind() != ExpressionKind::ObservableRef || + operand.args().size() != 1 || + operand.args().front().kind() != ExpressionKind::Identifier) { + return false; + } + functionName = operand.name(); + argument = operand.args().front().name(); + if (system->getLocalFunctionByName(functionName) == NULL) return false; + return argument == composite->getArgName(0) || + argument == composite->getArgName(1); + }; + + if (!parseOperand(expression.args()[0], result.functionName1, result.argument1) || + !parseOperand(expression.args()[1], result.functionName2, result.argument2) || + result.argument1 == result.argument2) { + return false; + } + return true; +} + bool readBareProductFilterMolecule( TiXmlElement *pPattern, string &moleculeName, string &diagnostic) { @@ -1384,6 +1436,41 @@ bool validateXmlProductFilters( return true; } +bool findReactantComponentState( + TiXmlElement *pListOfReactantPatterns, const string &componentId, + string &state) +{ + if (!pListOfReactantPatterns) return false; + for (TiXmlElement *pReactant = + pListOfReactantPatterns->FirstChildElement("ReactantPattern"); + pReactant != NULL; + pReactant = pReactant->NextSiblingElement("ReactantPattern")) { + TiXmlElement *pListOfMolecules = + pReactant->FirstChildElement("ListOfMolecules"); + if (!pListOfMolecules) continue; + for (TiXmlElement *pMolecule = + pListOfMolecules->FirstChildElement("Molecule"); + pMolecule != NULL; + pMolecule = pMolecule->NextSiblingElement("Molecule")) { + TiXmlElement *pListOfComponents = + pMolecule->FirstChildElement("ListOfComponents"); + if (!pListOfComponents) continue; + for (TiXmlElement *pComponent = + pListOfComponents->FirstChildElement("Component"); + pComponent != NULL; + pComponent = pComponent->NextSiblingElement("Component")) { + if (pComponent->Attribute("id") && + componentId == pComponent->Attribute("id") && + pComponent->Attribute("state")) { + state = pComponent->Attribute("state"); + return true; + } + } + } + } + return false; +} + } // namespace bool NFinput::initReactionRules( @@ -1541,9 +1628,14 @@ bool NFinput::initReactionRules( - /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// - vector productCompartments; - //Read in the list of operations we need to perform in this rule + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + vector productCompartments; + string arrheniusStateComponent; + string arrheniusStateFrom; + string arrheniusStateTo; + MoleculeType *arrheniusStateMoleculeType = NULL; + int arrheniusStateChangeCount = 0; + //Read in the list of operations we need to perform in this rule TiXmlElement *pListOfOperations = pRxnRule->FirstChildElement("ListOfOperations"); if ( !pListOfOperations ) { @@ -1921,6 +2013,13 @@ bool NFinput::initReactionRules( return false; } + ++arrheniusStateChangeCount; + arrheniusStateTo = finalState; + arrheniusStateComponent = c->symPermutationName; + arrheniusStateMoleculeType = c->t->getMoleculeType(); + findReactantComponentState( + pListOfReactantPatterns, site, arrheniusStateFrom); + //handle both increment and decrement states first... if(finalState=="PLUS") { if(!ts->addIncrementStateTransform(c->t,c->symPermutationName)) return false; @@ -2382,6 +2481,18 @@ bool NFinput::initReactionRules( if(rateLawType=="Arrhenius") { + bool includeArrheniusReverse = true; + if (pRxnRule->Attribute("energyIncludeReverse")) { + try { + includeArrheniusReverse = + NFutil::convertToInt( + pRxnRule->Attribute("energyIncludeReverse")) != 0; + } catch (std::runtime_error &e1) { + cerr << "Error!! energyIncludeReverse for ReactionRule " + << rxnName << " was not set properly. Quitting." << endl; + return false; + } + } // Energy-based rule: expand into multiple BasicRxnClass instances // using the Sekar rule expansion algorithm. // @@ -2389,7 +2500,7 @@ bool NFinput::initReactionRules( // forward (_R1): has AddBond op -> addBondSite1/2 populated // reverse (_reverse__R1): has DeleteBond -> addBondSite1/2 empty // The forward expansion creates BOTH directions, so skip the reverse. - if (addBondSite1.empty()) { + if (addBondSite1.empty() && arrheniusStateChangeCount == 0) { if(verbose) cout << "\t\t\tSkipping reverse Arrhenius rule '" << rxnName << "' -- reverse already created by forward expansion." << endl; @@ -2442,6 +2553,42 @@ bool NFinput::initReactionRules( } } + if (addBondSite1.empty()) { + if (arrheniusStateChangeCount != 1 || + arrheniusStateMoleculeType == NULL || + arrheniusStateFrom.empty() || arrheniusStateTo.empty() || + arrheniusStateTo == "PLUS" || arrheniusStateTo == "MINUS") { + cerr << "Arrhenius state-change rule " << rxnName + << " needs one explicit source and destination state." << endl; + delete ts; + return false; + } + + const std::size_t firstNewReaction = s->getAllReactions().size(); + if (!NFinput::createExpandedStateChangeReactions( + rxnName, rule_phi, Ea0, arrheniusStateMoleculeType, + arrheniusStateComponent, arrheniusStateFrom, arrheniusStateTo, + s, blockSameComplexBinding, verbose, reaction_count, + includeArrheniusReverse)) { + delete ts; + return false; + } + const auto reactions = s->getAllReactions(); + for (std::size_t reactionIndex = firstNewReaction; + reactionIndex < reactions.size(); ++reactionIndex) { + auto *reaction = reactions[reactionIndex]; + reaction->setTotalRateFlag(totalRateFlag); + for (std::size_t reactantIndex = 0; + reactantIndex < matchOnceList.size(); ++reactantIndex) { + reaction->setMatchOnce( + static_cast(reactantIndex), + matchOnceList[reactantIndex]); + } + } + delete ts; + continue; + } + // Validate: must be a 2-reactant binding rule if(ts->getNreactants() != 2) { @@ -2455,7 +2602,8 @@ bool NFinput::initReactionRules( if(!NFinput::createExpandedBindingReactions( rxnName, rule_phi, Ea0, mt1, addBondSite1, mt2, addBondSite2, - s, parameter, allowedStates, blockSameComplexBinding, verbose, reaction_count)) + s, parameter, allowedStates, blockSameComplexBinding, verbose, reaction_count, + includeArrheniusReverse)) { return false; } @@ -2624,8 +2772,58 @@ bool NFinput::initReactionRules( } else { string functionName = pRateLaw->Attribute("name"); + CompositeFunction *composite = s->getCompositeFunctionByName(functionName); + LegacyCompositeDOR2 legacyDOR2; + if (parseLegacyCompositeDOR2(composite, s, legacyDOR2)) { + if (funcArgs.size() != 2 || + find(funcArgs.begin(), funcArgs.end(), legacyDOR2.argument1) == funcArgs.end() || + find(funcArgs.begin(), funcArgs.end(), legacyDOR2.argument2) == funcArgs.end()) { + cerr << "Error: legacy DOR2 rate law '" << functionName + << "' has invalid reaction arguments." << endl; + return false; + } + + const auto ensureLocalWrapper = [&](const string &localName, + const string &argument) -> CompositeFunction * { + if (s->getLocalFunctionByName(localName) == NULL) return NULL; + auto *wrapper = s->getCompositeFunctionByName(localName); + if (wrapper == NULL) { + vector calledFunctions(1, localName); + vector wrapperArgs(1, argument); + vector wrapperParams; + wrapper = new CompositeFunction( + s, localName, localName + "(" + argument + ")", + calledFunctions, wrapperArgs, wrapperParams); + if (!s->addCompositeFunction(wrapper)) { + delete wrapper; + return NULL; + } + wrapper->finalizeInitialization(s); + } else if (wrapper->getNumOfArgs() != 1 || + wrapper->getArgName(0) != argument) { + return NULL; + } + return wrapper; + }; + + CompositeFunction *cf1 = ensureLocalWrapper( + legacyDOR2.functionName1, legacyDOR2.argument1); + CompositeFunction *cf2 = ensureLocalWrapper( + legacyDOR2.functionName2, legacyDOR2.argument2); + if (cf1 == NULL || cf2 == NULL) { + cerr << "Error: could not resolve legacy DOR2 factors for '" + << functionName << "'." << endl; + return false; + } + + vector funcArgs1(1, legacyDOR2.argument1); + vector funcArgs2(1, legacyDOR2.argument2); + ts->finalize(); + r = new DOR2RxnClass( + rxnName, 1, "", ts, cf1, cf2, funcArgs1, funcArgs2, s); + } else { LocalFunction *lf = s->getLocalFunctionByName(functionName); - if(lf!=NULL) { + if(lf!=NULL) { // The XML writer may retain the source-level local // function name. DOR reactions consume a composite // pointer, so create the same thin wrapper used by the @@ -2648,14 +2846,15 @@ bool NFinput::initReactionRules( } } - ts->finalize(); - CompositeFunction *cf = s->getCompositeFunctionByName(functionName); + ts->finalize(); + CompositeFunction *cf = s->getCompositeFunctionByName(functionName); if (cf == NULL) { cerr<<"Error: could not resolve local-function wrapper '" < #include #include +#include using namespace std; using namespace NFcore; @@ -159,7 +160,8 @@ bool createExpandedBindingReactions( map &allowedStates, bool blockSameComplexBinding, bool verbose, - int &reaction_count) + int &reaction_count, + bool includeReverse) { EnergyFunction *ef = s->getEnergyFunction(); if (!ef) return false; @@ -167,11 +169,90 @@ bool createExpandedBindingReactions( string mt1Name = molType1->getName(); string mt2Name = molType2->getName(); - // Run the expansion algorithm + /* A factorized context can be evaluated from the selected reaction + * mapping. Keep the legacy materialized expansion for contexts that span + * both reactants or whose energy pattern needs multiple conditions + * simultaneously; those cases need a more general representation. */ + EnergyBindingContext compactContext; + bool useCompact = ef->getBindingContext( + mt1Name, bindSite1, mt2Name, bindSite2, compactContext); + if (mt1Name == mt2Name) useCompact = false; + if (useCompact) { + int contextReactant = -1; + for (const auto &condition : compactContext.conditions) { + if (contextReactant < 0) contextReactant = condition.reactantIdx; + if (condition.reactantIdx != contextReactant) { + useCompact = false; + break; + } + } + + /* A compact term may be gated by a conjunction of occupancy + * predicates on the selected weighted molecule. */ + for (const auto &term : compactContext.conditionalTerms) { + if (term.conditionMask == 0) { + useCompact = false; + break; + } + } + + /* Restrict the initial compact path to contexts on reactant 0 so + * both directions use the same direct molecule mapping without + * changing any other Arrhenius behavior. */ + if (contextReactant != 0) useCompact = false; + } + + if (useCompact) { + for (int direction = 0; direction < 2; direction++) { + const bool isForward = (direction == 0); + if (!includeReverse && !isForward) continue; + TemplateMolecule *t1 = new TemplateMolecule(molType1); + TemplateMolecule *t2 = new TemplateMolecule(molType2); + + if (isForward) { + t1->addEmptyComponent(bindSite1); + t2->addEmptyComponent(bindSite2); + } else { + TemplateMolecule::bind(t1, bindSite1, "", t2, bindSite2, ""); + } + + vector templates; + templates.push_back(t1); + if (isForward) templates.push_back(t2); + + TransformationSet *ts = new TransformationSet(templates); + if (isForward) ts->addBindingTransform(t1, bindSite1, t2, bindSite2); + else ts->addUnbindingTransform(t1, bindSite1, t2, bindSite2); + ts->setComplexBookkeeping(blockSameComplexBinding); + ts->finalize(); + + /* Pull out the activation-only term as DOR's base rate. The + * EnergyRxnClass supplies the context factor for each mapping. */ + double activationRate = std::exp(-Ea0 / ef->getRT()); + string directionName = rxnName + (isForward ? "_fwd" : "_rev"); + EnergyRxnClass *r = new EnergyRxnClass( + directionName, activationRate, "", ts, 0, compactContext, + phi_val, ef->getRT(), isForward, s); + + s->addReaction(r); + reaction_count++; + + if (verbose) { + cout << "\t Created compact " + << (isForward ? "forward" : "reverse") + << " rule: " << directionName + << " (mapping-local Arrhenius context)" << endl; + } + } + return true; + } + + // Run the legacy expansion algorithm for non-factorized contexts. vector expanded = ef->expandBindingRule( rxnName, Ea0, phi_val, mt1Name, bindSite1, mt2Name, bindSite2); for (const auto &rule : expanded) { + if (!includeReverse && !rule.isForward) continue; TemplateMolecule *t1, *t2; if (rule.isForward) { @@ -234,7 +315,8 @@ bool createExpandedStateChangeReactions( System *s, bool blockSameComplexBinding, bool verbose, - int &reaction_count) + int &reaction_count, + bool includeReverse) { EnergyFunction *ef = s->getEnergyFunction(); if (!ef || !molType || stateFrom.empty() || stateTo.empty()) return false; @@ -243,6 +325,7 @@ bool createExpandedStateChangeReactions( rxnName, Ea0, phi_val, molType->getName(), component, stateFrom, stateTo); for (const auto &rule : expanded) { + if (!includeReverse && !rule.isForward) continue; const string &fromState = rule.isForward ? stateFrom : stateTo; const string &toState = rule.isForward ? stateTo : stateFrom; if (molType->isEquivalentComponent(component)) return false; diff --git a/cpp/nfsim/NFinput/NFinput_energy.hh b/cpp/nfsim/NFinput/NFinput_energy.hh index f6582ea6..9453b9dc 100644 --- a/cpp/nfsim/NFinput/NFinput_energy.hh +++ b/cpp/nfsim/NFinput/NFinput_energy.hh @@ -43,7 +43,8 @@ namespace NFinput { std::map &allowedStates, bool blockSameComplexBinding, bool verbose, - int &reaction_count); + int &reaction_count, + bool includeReverse); /* * Create expanded BasicRxnClass instances for an energy state-change rule. @@ -59,7 +60,8 @@ namespace NFinput { NFcore::System *s, bool blockSameComplexBinding, bool verbose, - int &reaction_count); + int &reaction_count, + bool includeReverse); } // namespace NFinput diff --git a/cpp/nfsim/NFinput/NFinput_fromAst.cpp b/cpp/nfsim/NFinput/NFinput_fromAst.cpp index 1ac406a6..32ce021d 100644 --- a/cpp/nfsim/NFinput/NFinput_fromAst.cpp +++ b/cpp/nfsim/NFinput/NFinput_fromAst.cpp @@ -56,6 +56,9 @@ #include "../NFfunction/NFfunction.hh" #include "../NFutil/NFutil.hh" #include "NFinput_energy.hh" + +#include "parser/antlr_compat.hpp" + #include "PatternGraphBuilder.hpp" #include "BNGLexer.h" #include "BNGParser.h" @@ -360,6 +363,15 @@ std::string expressionForNfsim(const bng::ast::Expression& expression) { return {}; } +// NFsim's legacy CompositeFunction reserves reactant_1 through reactant_9 for +// the current reaction's mapping counts. They are not model functions or +// observables, even though the BNGL expression grammar represents the call as +// a function-shaped reference. +bool isReactantCountReference(const std::string& name) { + return name.size() == 10 && name.compare(0, 9, "reactant_") == 0 && + name.back() >= '1' && name.back() <= '9'; +} + bool expandDynamicRateExpression( const bng::ast::Expression& expression, const bng::ast::Model& model, @@ -368,12 +380,33 @@ bool expandDynamicRateExpression( std::set& functionCounterReferences, std::set& parameterReferences, std::vector& tableFunctions, + std::set* localFunctionReferences, + std::set* localFunctionArgumentNames, bool& usesTime, std::set& activeFunctions, std::string& expanded, std::string& diagnostic) { using bng::ast::ExpressionKind; + const auto preserveScopedModelFunctionCall = + [&](const bng::ast::Function& function, + const bng::ast::Expression& call, + std::string& result) { + if (localFunctionReferences == nullptr || + localFunctionArgumentNames == nullptr || + function.getArgs().size() != 1 || call.args().size() != 1 || + call.args().front().kind() != ExpressionKind::Identifier) { + diagnostic = "dynamic rates require one scope identifier for " + "argument-bearing model function '" + function.getName() + "'"; + return false; + } + const auto& argument = call.args().front().name(); + localFunctionReferences->insert(function.getName()); + localFunctionArgumentNames->insert(argument); + result = function.getName() + "(" + argument + ")"; + return true; + }; + const auto expandModelFunction = [&](const bng::ast::Function& function, std::string& result) { if (!function.getArgs().empty()) { @@ -389,7 +422,8 @@ bool expandDynamicRateExpression( std::string body; const bool ok = expandDynamicRateExpression( function.getExpression(), model, parameters, observableReferences, - functionCounterReferences, parameterReferences, tableFunctions, usesTime, + functionCounterReferences, parameterReferences, tableFunctions, + localFunctionReferences, localFunctionArgumentNames, usesTime, activeFunctions, body, diagnostic); activeFunctions.erase(function.getName()); if (ok) result = "(" + body + ")"; @@ -427,6 +461,11 @@ bool expandDynamicRateExpression( expanded = name; return true; } + if (isReactantCountReference(name)) { + functionCounterReferences.insert(name); + expanded = name; + return true; + } if (const auto* function = getModelFunction(model, name)) { if (!expressionHasModelFunctionReference( function->getExpression(), expressionHasModelFunctionReference)) { @@ -452,7 +491,8 @@ bool expandDynamicRateExpression( std::string operand; if (!expandDynamicRateExpression( expression.args().front(), model, parameters, observableReferences, - functionCounterReferences, parameterReferences, tableFunctions, usesTime, + functionCounterReferences, parameterReferences, tableFunctions, + localFunctionReferences, localFunctionArgumentNames, usesTime, activeFunctions, operand, diagnostic)) { return false; } @@ -468,11 +508,13 @@ bool expandDynamicRateExpression( std::string rhs; if (!expandDynamicRateExpression( expression.args()[0], model, parameters, observableReferences, - functionCounterReferences, parameterReferences, tableFunctions, usesTime, + functionCounterReferences, parameterReferences, tableFunctions, + localFunctionReferences, localFunctionArgumentNames, usesTime, activeFunctions, lhs, diagnostic) || !expandDynamicRateExpression( expression.args()[1], model, parameters, observableReferences, - functionCounterReferences, parameterReferences, tableFunctions, usesTime, + functionCounterReferences, parameterReferences, tableFunctions, + localFunctionReferences, localFunctionArgumentNames, usesTime, activeFunctions, rhs, diagnostic)) { return false; } @@ -491,12 +533,19 @@ bool expandDynamicRateExpression( expanded = lowerName; return true; } - if (const auto* function = getModelFunction(model, name)) { + if (isReactantCountReference(name)) { if (!expression.args().empty()) { - diagnostic = "dynamic rates cannot call zero-argument model function '" + - name + "' with arguments"; + diagnostic = name + "() takes no arguments"; return false; } + functionCounterReferences.insert(name); + expanded = name + "()"; + return true; + } + if (const auto* function = getModelFunction(model, name)) { + if (!expression.args().empty()) { + return preserveScopedModelFunctionCall(*function, expression, expanded); + } if (!expressionHasModelFunctionReference( function->getExpression(), expressionHasModelFunctionReference)) { functionCounterReferences.insert(name); @@ -522,7 +571,8 @@ bool expandDynamicRateExpression( if (!expandDynamicRateExpression( expression.args()[index], model, parameters, observableReferences, functionCounterReferences, parameterReferences, tableFunctions, - usesTime, activeFunctions, argument, diagnostic)) { + localFunctionReferences, localFunctionArgumentNames, usesTime, + activeFunctions, argument, diagnostic)) { return false; } output << argument; @@ -533,12 +583,19 @@ bool expandDynamicRateExpression( } case ExpressionKind::ObservableRef: { const auto& name = expression.name(); - if (const auto* function = getModelFunction(model, name)) { + if (isReactantCountReference(name)) { if (!expression.args().empty()) { - diagnostic = "dynamic rates cannot call zero-argument model function '" + - name + "' with arguments"; + diagnostic = name + "() takes no arguments"; return false; } + functionCounterReferences.insert(name); + expanded = name + "()"; + return true; + } + if (const auto* function = getModelFunction(model, name)) { + if (!expression.args().empty()) { + return preserveScopedModelFunctionCall(*function, expression, expanded); + } if (!expressionHasModelFunctionReference( function->getExpression(), expressionHasModelFunctionReference)) { functionCounterReferences.insert(name); @@ -983,6 +1040,14 @@ bool collectLocalFunctionReferences( } return true; } + if (hasModelObservable(model, expression.name())) { + // BNGL permits a zero-argument observable in a local function + // without an explicit call, e.g. ``k*Total + 0*Scoped(x)``. + // NFsim's legacy local-function constructor treats this as a + // global-scope observable reference. + addReference(expression.name(), "Observable"); + return true; + } diagnostic = "unknown local-function identifier '" + expression.name() + "'"; return false; case ExpressionKind::Unary: @@ -1213,17 +1278,31 @@ bool addMoleculeTypesFromAst(const bng::ast::Model& model, System* s, std::map& allowedStates, bool verbose) { try { + std::vector moleculeTypes; + moleculeTypes.reserve(model.getMoleculeTypes().size()); for (const auto& moleculeType : model.getMoleculeTypes()) { + moleculeTypes.push_back(&moleculeType); + } + std::sort(moleculeTypes.begin(), moleculeTypes.end(), + [](const auto* left, const auto* right) { + return left->getName() < right->getName(); + }); + + for (const auto* moleculeTypePtr : moleculeTypes) { + const auto& moleculeType = *moleculeTypePtr; const std::string& typeName = moleculeType.getName(); if (typeName.empty()) { std::cerr << "[nfsim/ast] molecule type is missing a name\n"; return false; } + // Null is an NFsim keyword, not a constructible molecule type. + // It is accepted in product positions as the degradation sink, + // but NFsim rejects it in reactant and observable patterns. const std::string normalizedName = lowerCase(typeName); - if (normalizedName == "null" || normalizedName == "trash") { + if (normalizedName == "null") { if (verbose) { - std::cerr << "[nfsim/ast] skipping molecule type '" << typeName - << "'\n"; + std::cerr << "[nfsim/ast] skipping reserved molecule type '" + << typeName << "'\n"; } continue; } @@ -1290,15 +1369,24 @@ bool addMoleculeTypesFromAst(const bng::ast::Model& model, System* s, bool hasNegativeInteger = false; int maximumState = -1; for (const auto& state : component.allowedStates) { + // `?` is a pattern wildcard, never a constructible + // state. NFinput::initMoleculeTypes also keeps PLUS and + // MINUS out of the string-state count so an otherwise + // numeric component remains an integer site; they are + // transition sentinels consumed by the rule builder. + if (state == "?") { + continue; + } int integerValue = 0; if (parseIntegerState(state, integerValue)) { hasIntegerState = true; hasNegativeInteger = hasNegativeInteger || integerValue < 0; maximumState = std::max(maximumState, integerValue); } else { - hasStringState = true; if (state == "PLUS" || state == "MINUS") { hasPlusMinusState = true; + } else { + hasStringState = true; } } } @@ -1323,6 +1411,9 @@ bool addMoleculeTypesFromAst(const bng::ast::Model& model, System* s, << "' are labels, not integer increments\n"; } for (const auto& state : component.allowedStates) { + if (state == "?") { + continue; + } if (std::find(states.begin(), states.end(), state) == states.end()) { states.push_back(state); } @@ -1585,8 +1676,8 @@ bool addFunctionsFromAst(const bng::ast::Model& model, System* s, std::string diagnostic; if (!expandDynamicRateExpression( function.getExpression(), model, parameters, observableReferences, - functionReferences, parameterReferences, tableFunctions, usesTime, - activeFunctions, expandedExpression, diagnostic)) { + functionReferences, parameterReferences, tableFunctions, nullptr, nullptr, + usesTime, activeFunctions, expandedExpression, diagnostic)) { std::cerr << "[nfsim/ast] cannot map function '" << function.getName() << "': " << diagnostic << "\n"; return false; @@ -1790,6 +1881,10 @@ bool addFunctionsFromAst(const bng::ast::Model& model, System* s, std::vector functionsCalled; for (const auto& dependency : function.functionReferences) { + if (isReactantCountReference(dependency)) { + functionsCalled.push_back(dependency); + continue; + } const auto* target = getModelFunction(model, dependency); const auto targetPending = std::find_if( pending.begin(), pending.end(), @@ -1918,6 +2013,71 @@ std::string graphBondToken(const BNGcore::Node& node) { return node.get_state().get_BNG2_string(); } +std::string graphSortState(const GraphComponent& component) { + const auto state = graphStateToken(*component.node); + // buildPatternGraph represents an omitted seed state as the graph + // wildcard `?`; BNG2's source SpeciesGraph represents that same input as + // an undefined state for quasi-canonical ordering. + return state == "?" ? std::string() : state; +} + +std::size_t graphComponentBondCount(const GraphComponent& component) { + return static_cast(std::count_if( + component.bonds.begin(), component.bonds.end(), [](const auto* bond) { + return graphBondToken(*bond) != "!-"; + })); +} + +bool graphComponentLess(const GraphComponent& left, + const GraphComponent& right) { + if (left.name != right.name) return left.name < right.name; + const auto leftState = graphSortState(left); + const auto rightState = graphSortState(right); + if (leftState != rightState) { + if (leftState.empty() != rightState.empty()) return leftState.empty(); + return leftState < rightState; + } + // BNG2's cmp_component puts components with more edges first after name + // and state comparisons. + const auto leftBondCount = graphComponentBondCount(left); + const auto rightBondCount = graphComponentBondCount(right); + if (leftBondCount != rightBondCount) { + return leftBondCount > rightBondCount; + } + return false; +} + +bool graphMoleculeLess(const GraphMolecule& left, + const GraphMolecule& right) { + if (left.name != right.name) return left.name < right.name; + if (left.components.size() != right.components.size()) { + return left.components.size() < right.components.size(); + } + + const auto leftCompartment = left.node->get_compartment(); + const auto rightCompartment = right.node->get_compartment(); + if (leftCompartment.empty() != rightCompartment.empty()) { + return leftCompartment.empty(); + } + if (leftCompartment != rightCompartment) return leftCompartment < rightCompartment; + + for (std::size_t index = 0; index < left.components.size(); ++index) { + if (graphComponentLess(left.components[index], right.components[index])) return true; + if (graphComponentLess(right.components[index], left.components[index])) return false; + } + return false; +} + +std::vector canonicalizeSeedGraphMolecules( + std::vector molecules) { + for (auto& molecule : molecules) { + std::stable_sort(molecule.components.begin(), molecule.components.end(), + graphComponentLess); + } + std::stable_sort(molecules.begin(), molecules.end(), graphMoleculeLess); + return molecules; +} + bool parseObservablePattern(const std::string& text, const bng::ast::Model& model, ParsedObservablePattern& result, @@ -2756,6 +2916,7 @@ bool addDynamicReactionRateFunction( std::size_t ordinal, GlobalFunction*& global, CompositeFunction*& composite, + std::vector& localFunctionArgumentNames, std::string& diagnostic); enum class SymmetricReactionExpansionResult { @@ -2945,9 +3106,14 @@ SymmetricReactionExpansionResult addSymmetricStateChangeReactionRulesFromAst( GlobalFunction* dynamicGlobal = nullptr; CompositeFunction* dynamicComposite = nullptr; + std::vector dynamicLocalFunctionArguments; if (!staticRate && !addDynamicReactionRateFunction( rule.getRates().front(), model, parameters, system, sourcePath, - rateOrdinal, dynamicGlobal, dynamicComposite, rateDiagnostic)) { + rateOrdinal, dynamicGlobal, dynamicComposite, + dynamicLocalFunctionArguments, rateDiagnostic)) { + return SymmetricReactionExpansionResult::NotApplicable; + } + if (!dynamicLocalFunctionArguments.empty()) { return SymmetricReactionExpansionResult::NotApplicable; } @@ -3147,9 +3313,14 @@ SymmetricReactionExpansionResult addSymmetricBondReactionRulesFromAst( GlobalFunction* dynamicGlobal = nullptr; CompositeFunction* dynamicComposite = nullptr; + std::vector dynamicLocalFunctionArguments; if (!staticRate && !addDynamicReactionRateFunction( rule.getRates().front(), model, parameters, system, sourcePath, - rateOrdinal, dynamicGlobal, dynamicComposite, rateDiagnostic)) { + rateOrdinal, dynamicGlobal, dynamicComposite, + dynamicLocalFunctionArguments, rateDiagnostic)) { + return SymmetricReactionExpansionResult::NotApplicable; + } + if (!dynamicLocalFunctionArguments.empty()) { return SymmetricReactionExpansionResult::NotApplicable; } @@ -3294,12 +3465,16 @@ bool addDynamicReactionRateFunction( std::size_t ordinal, GlobalFunction*& global, CompositeFunction*& composite, + std::vector& localFunctionArgumentNamesOut, std::string& diagnostic) { global = nullptr; composite = nullptr; + localFunctionArgumentNamesOut.clear(); std::set observableReferences; std::set functionReferences; + std::set localFunctionReferences; + std::set localFunctionArgumentNames; std::set parameterReferences; std::vector tableFunctions; bool usesTime = false; @@ -3307,7 +3482,8 @@ bool addDynamicReactionRateFunction( std::string expandedExpression; if (!expandDynamicRateExpression( expression, model, parameters, observableReferences, - functionReferences, parameterReferences, tableFunctions, usesTime, + functionReferences, parameterReferences, tableFunctions, + &localFunctionReferences, &localFunctionArgumentNames, usesTime, activeFunctions, expandedExpression, diagnostic)) { return false; } @@ -3317,6 +3493,7 @@ bool addDynamicReactionRateFunction( } if (!usesTime && observableReferences.empty() && functionReferences.empty() && + localFunctionReferences.empty() && tableFunctions.empty()) { diagnostic = "rate expression is not a supported dynamic function"; return false; @@ -3342,7 +3519,7 @@ bool addDynamicReactionRateFunction( std::vector parameterNames( parameterReferences.begin(), parameterReferences.end()); - if (!functionReferences.empty()) { + if (!functionReferences.empty() || !localFunctionReferences.empty()) { // CompositeFunction can depend on live observable values only through // GlobalFunction objects. Materialize a zero-argument alias for each // direct observable, then rewrite the generated expression to refer to @@ -3380,19 +3557,36 @@ bool addDynamicReactionRateFunction( } for (const auto& dependency : functionReferences) { + if (isReactantCountReference(dependency)) { + // CompositeFunction binds reactant_N at preparation time from + // the current reaction mapping. It is not a System global, + // even though the source-shaped expression retains the name. + continue; + } if (system->getGlobalFunctionByName(dependency) == nullptr) { diagnostic = "dynamic reaction rates only support references to base global " "functions; '" + dependency + "' is composite or unavailable"; return false; } } + for (const auto& dependency : localFunctionReferences) { + if (system->getLocalFunctionByName(dependency) == nullptr) { + diagnostic = "dynamic reaction rate references unavailable local function '" + + dependency + "'"; + return false; + } + } std::vector functionsCalled( functionReferences.begin(), functionReferences.end()); for (const auto& [observableName, aliasName] : observableAliases) { (void)observableName; functionsCalled.push_back(aliasName); } - std::vector argumentNames; + functionsCalled.insert( + functionsCalled.end(), localFunctionReferences.begin(), + localFunctionReferences.end()); + std::vector argumentNames( + localFunctionArgumentNames.begin(), localFunctionArgumentNames.end()); const auto compositeExpression = replaceNamedReferences( expandedExpression, observableAliases); auto candidate = std::make_unique( @@ -3410,6 +3604,7 @@ bool addDynamicReactionRateFunction( } composite = candidate.release(); composite->finalizeInitialization(system); + localFunctionArgumentNamesOut = std::move(argumentNames); if (usesTime) { composite->setCounterFromTime(system); system->setHasTimeDependentFunctions(true); @@ -3603,7 +3798,7 @@ bool addDirectArrheniusBinding(const bng::ast::ReactionRule& rule, System* syste const std::map& parameters, bool blockSameComplexBinding, bool verbose, int& suggestedTraversalLimit) { - if (!rule.isBidirectional() || rule.getRates().size() != 1 || + if (rule.getRates().size() != 1 || !isArrheniusExpression(rule.getRates().front())) { return false; } @@ -3689,7 +3884,7 @@ bool addDirectArrheniusBinding(const bng::ast::ReactionRule& rule, System* syste if (!createExpandedBindingReactions( rule.getRuleName(), phi, activationEnergy, moleculeType1, site1, moleculeType2, site2, system, unusedParameters, unusedStates, - blockSameComplexBinding, verbose, reactionCount)) { + blockSameComplexBinding, verbose, reactionCount, rule.isBidirectional())) { return false; } @@ -3713,7 +3908,7 @@ bool addDirectArrheniusStateChange(const bng::ast::ReactionRule& rule, System* s const std::map& parameters, bool blockSameComplexBinding, bool verbose, int& suggestedTraversalLimit) { - if (!rule.isBidirectional() || rule.getRates().size() != 1 || + if (rule.getRates().size() != 1 || !isArrheniusExpression(rule.getRates().front())) { return false; } @@ -3789,7 +3984,7 @@ bool addDirectArrheniusStateChange(const bng::ast::ReactionRule& rule, System* s if (!createExpandedStateChangeReactions( rule.getRuleName(), phi, activationEnergy, moleculeType, component, stateFrom, stateChange->newState, system, blockSameComplexBinding, - verbose, reactionCount)) { + verbose, reactionCount, rule.isBidirectional())) { return false; } @@ -3822,6 +4017,7 @@ bool isDiscardMolecule(const GraphMolecule& molecule) { struct DirectProductMolecule { TemplateMolecule* templateMolecule = nullptr; MoleculeCreator* creator = nullptr; + std::vector runtimeComponentNames; }; bool buildDirectProductMolecule(const bng::ast::SpeciesGraph& pattern, @@ -3866,6 +4062,8 @@ bool buildDirectProductMolecule(const bng::ast::SpeciesGraph& pattern, } std::vector> componentStates; + std::vector runtimeComponentNames; + std::map symmetricComponentUse; std::set specifiedComponents; for (const auto& component : molecule.components) { if (component.bonds.size() > 1) { @@ -3900,22 +4098,36 @@ bool buildDirectProductMolecule(const bng::ast::SpeciesGraph& pattern, delete templateMolecule; return false; } - if (endpoints[0].first == endpoints[1].first) { - diagnostic = "intra-molecule product bonds are not yet supported"; - delete templateMolecule; - return false; - } + // NFsim's legacy XML loader permits a product bond whose two + // endpoints are on the same newly created molecule. The + // TransformationSet supports this mapping by assigning the + // second endpoint the next mapping slot, so keep the graph + // edge and let the product-bond transform install it. } } + std::string runtimeName = component.name; if (moleculeType->isEquivalentComponent(component.name)) { - diagnostic = "symmetric product components require permutation expansion"; - delete templateMolecule; - return false; + std::vector equivalentNames; + if (!getEquivalentNames(moleculeType, component.name, equivalentNames)) { + diagnostic = "could not resolve symmetric product component '" + + component.name + "'"; + delete templateMolecule; + return false; + } + auto& useCount = symmetricComponentUse[component.name]; + if (useCount >= equivalentNames.size()) { + diagnostic = "too many symmetric product components named '" + + component.name + "'"; + delete templateMolecule; + return false; + } + runtimeName = equivalentNames[useCount++]; } + runtimeComponentNames.push_back(runtimeName); int componentIndex = -1; try { - componentIndex = moleculeType->getCompIndexFromName(component.name); + componentIndex = moleculeType->getCompIndexFromName(runtimeName); } catch (const std::exception& error) { diagnostic = error.what(); delete templateMolecule; @@ -3929,7 +4141,7 @@ bool buildDirectProductMolecule(const bng::ast::SpeciesGraph& pattern, const std::string state = graphStateToken(*component.node); const int stateValue = componentStateValue( - moleculeType, component.name, state, diagnostic); + moleculeType, runtimeName, state, diagnostic); if (stateValue == -2) { delete templateMolecule; return false; @@ -3937,7 +4149,7 @@ bool buildDirectProductMolecule(const bng::ast::SpeciesGraph& pattern, if (stateValue >= 0) { componentStates.emplace_back(componentIndex, stateValue); try { - templateMolecule->addComponentConstraint(component.name, stateValue); + templateMolecule->addComponentConstraint(runtimeName, stateValue); } catch (const std::exception& error) { diagnostic = error.what(); delete templateMolecule; @@ -3947,6 +4159,7 @@ bool buildDirectProductMolecule(const bng::ast::SpeciesGraph& pattern, } result.templateMolecule = templateMolecule; + result.runtimeComponentNames = std::move(runtimeComponentNames); result.creator = new MoleculeCreator( templateMolecule, moleculeType, componentStates, compartment); return true; @@ -4179,28 +4392,65 @@ bool addObservablesFromAst(const bng::ast::Model& model, System* s, return true; } -bool addSpeciesFromAst(const bng::ast::Model& model, System* s, - const std::map& parameters, bool verbose) { +bool addSpeciesFromAstWithOverrides( + const bng::ast::Model& model, System* s, + const std::map& parameters, bool verbose, + const SeedAmountOverrides& seedAmountOverrides) { if (s == nullptr) return false; for (const auto& seed : model.getSeedSpecies()) { - const auto molecules = collectGraphMolecules(seed.getGraph()); + const auto molecules = canonicalizeSeedGraphMolecules( + collectGraphMolecules(seed.getGraph())); if (molecules.empty()) { std::cerr << "[nfsim/ast] seed species '" << seed.getPattern() << "' contains no molecule graph\n"; return false; } - // NFsim treats $Trash() (and the corresponding Trash molecule type) as - // a discard seed. The molecule-type builder omits this sentinel, so - // consume an all-trash seed here instead of reporting an unknown type. - if (std::all_of(molecules.begin(), molecules.end(), [](const auto& molecule) { - return lowerCase(molecule.name) == "trash"; - })) { + // A declared Null()/Trash() is an ordinary molecule in BNGL. Only + // fixed discard seeds (the `$Null`/`$Trash` convention) or an + // undeclared discard name are sentinels; preserving this distinction + // matters for models that use Null as a catalyst or observable. + const bool allDiscard = std::all_of( + molecules.begin(), molecules.end(), isDiscardMolecule); + const bool hasUndeclaredDiscard = std::any_of( + molecules.begin(), molecules.end(), [&](const auto& molecule) { + for (int index = 0; index < s->getNumOfMoleculeTypes(); ++index) { + if (s->getMoleculeType(index)->getName() == molecule.name) return false; + } + return true; + }); + if (allDiscard && (seed.isConstant() || hasUndeclaredDiscard)) { continue; } std::string diagnostic; int count = 0; - if (!parseSeedAmount(seed, parameters, count, diagnostic)) { + std::vector overrideKeys = { + seed.getPattern(), seed.getGraph().get_BNG2_string()}; + if (!seed.getCompartment().empty()) { + const auto& compartment = seed.getCompartment(); + overrideKeys.push_back("@" + compartment + "::" + seed.getPattern()); + overrideKeys.push_back("@" + compartment + ":" + seed.getPattern()); + overrideKeys.push_back( + "@" + compartment + "::" + seed.getGraph().get_BNG2_string()); + } + + auto overrideIt = seedAmountOverrides.end(); + for (const auto& key : overrideKeys) { + overrideIt = seedAmountOverrides.find(key); + if (overrideIt != seedAmountOverrides.end()) break; + } + if (overrideIt != seedAmountOverrides.end()) { + const double amount = overrideIt->second; + if (!std::isfinite(amount) || amount < 0.0 || + amount > static_cast(std::numeric_limits::max()) || + std::floor(amount) != amount) { + std::cerr << "[nfsim/ast] cannot map seed species '" + << seed.getPattern() + << "': action concentration must be a nonnegative integer\n"; + return false; + } + count = static_cast(amount); + } else if (!parseSeedAmount(seed, parameters, count, diagnostic)) { std::cerr << "[nfsim/ast] cannot map seed species '" << seed.getPattern() << "': " << diagnostic << "\n"; return false; @@ -4250,8 +4500,12 @@ bool addSpeciesFromAst(const bng::ast::Model& model, System* s, const int copies = foundPopulation ? 1 : count; std::vector> generated( molecules.size(), std::vector()); - for (int copy = 0; copy < copies; ++copy) { - for (std::size_t moleculeIndex = 0; moleculeIndex < molecules.size(); ++moleculeIndex) { + // Match NFinput::initStartSpecies' allocation order: all copies of a + // molecule position are allocated before advancing to the next + // position. The order is observable through MoleculeList IDs and is + // part of the seeded NFsim mapping stream for repeated molecules. + for (std::size_t moleculeIndex = 0; moleculeIndex < molecules.size(); ++moleculeIndex) { + for (int copy = 0; copy < copies; ++copy) { auto* moleculeType = s->getMoleculeTypeByName(molecules[moleculeIndex].name); auto* molecule = moleculeType->genDefaultMolecule(compartments[moleculeIndex]); generated[moleculeIndex].push_back(molecule); @@ -4272,7 +4526,9 @@ bool addSpeciesFromAst(const bng::ast::Model& model, System* s, runtimeNames[moleculeIndex][componentIndex], stateValue); } } + } + for (int copy = 0; copy < copies; ++copy) { std::unordered_set processedBonds; for (std::size_t moleculeIndex = 0; moleculeIndex < molecules.size(); ++moleculeIndex) { for (std::size_t componentIndex = 0; @@ -4290,6 +4546,13 @@ bool addSpeciesFromAst(const bng::ast::Model& model, System* s, } } } + // The legacy XML species reader treats a one-ended + // numeric bond as seed metadata (numberOfBonds=1), + // not as a runtime bond. This occurs in the bundled + // RNA NFsim fixtures. Preserve the same open-site + // runtime representation while still rejecting an + // impossible multi-ended bond. + if (endpoints.size() == 1) continue; if (endpoints.size() != 2) { std::cerr << "[nfsim/ast] seed species has an incomplete bound bond\n"; return false; @@ -4297,9 +4560,9 @@ bool addSpeciesFromAst(const bng::ast::Model& model, System* s, const auto [firstMolecule, firstComponent] = endpoints[0]; const auto [secondMolecule, secondComponent] = endpoints[1]; Molecule::bind( - generated[firstMolecule].back(), + generated[firstMolecule][copy], runtimeNames[firstMolecule][firstComponent], - generated[secondMolecule].back(), + generated[secondMolecule][copy], runtimeNames[secondMolecule][secondComponent]); } } @@ -4329,6 +4592,13 @@ bool addSpeciesFromAst(const bng::ast::Model& model, System* s, return true; } +bool addSpeciesFromAst(const bng::ast::Model& model, System* s, + const std::map& parameters, bool verbose) { + static const SeedAmountOverrides noOverrides; + return addSpeciesFromAstWithOverrides( + model, s, parameters, verbose, noOverrides); +} + bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, const std::map& parameters, bool blockSameComplexBinding, bool verbose, @@ -4357,8 +4627,7 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, const auto& originalRule = model.getReactionRules()[originalRuleOrdinal]; const auto& originalRates = originalRule.getRates(); const bool directArrhenius = - originalRule.isBidirectional() && originalRates.size() == 1 && - isArrheniusExpression(originalRates.front()); + originalRates.size() == 1 && isArrheniusExpression(originalRates.front()); if ((!originalRule.isBidirectional() && originalRates.size() != 1) || (originalRule.isBidirectional() && originalRates.size() != 2 && !directArrhenius)) { @@ -4372,7 +4641,7 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, hasReactionModifierPrefix(originalRule, "include_") || hasReactionModifierPrefix(originalRule, "exclude_") || hasReactionModifier(originalRule, "moveconnected")) { - std::cerr << "[nfsim/ast] direct Arrhenius binding uses an unsupported modifier\n"; + std::cerr << "[nfsim/ast] direct Arrhenius reaction uses an unsupported modifier\n"; return false; } const bool onlyStateChange = @@ -4439,11 +4708,16 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, (rates.front().kind() == bng::ast::ExpressionKind::ObservableRef || rates.front().kind() == bng::ast::ExpressionKind::Function) && rates.front().args().size() == rateFunction->getArgs().size(); - const bool functionProductRate = + const bool explicitFunctionProductRate = !rates.empty() && rates.front().kind() == bng::ast::ExpressionKind::Function && lowerCase(rates.front().name()) == "functionproduct" && rates.front().args().size() == 2; + const bool rawFunctionProductShape = + !rates.empty() && + rates.front().kind() == bng::ast::ExpressionKind::Binary && + rates.front().name() == "*" && rates.front().args().size() == 2; + bool functionProductRate = explicitFunctionProductRate; FunctionProductOperand functionProductOperand1; FunctionProductOperand functionProductOperand2; if (functionProductRate) { @@ -4459,6 +4733,22 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, << "\n"; return false; } + } else if (rawFunctionProductShape) { + // BNG2 emits a raw multiplication for DOR2 local functions + // (for example, rateA(x)*rateB(y)), while the explicit + // FunctionProduct form is also accepted as a bounded bridge. + // Recognize only the exact two one-argument local-function + // shape so ordinary arithmetic still follows the generic + // dynamic-rate path. + std::string productDiagnostic; + if (parseFunctionProductOperand( + model, rates.front().args()[0], functionProductOperand1, + productDiagnostic) && + parseFunctionProductOperand( + model, rates.front().args()[1], functionProductOperand2, + productDiagnostic)) { + functionProductRate = true; + } } const auto hasRateScope = [&](const std::string& argument) { return std::any_of( @@ -4476,7 +4766,37 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, !functionProductRate || (hasRateScope(functionProductOperand1.argument) && hasRateScope(functionProductOperand2.argument)); - if ((rule.hasScopePrefix() && !localFunctionRate && !functionProductRate) || + const auto hasScopedModelFunctionCall = + [&](const auto& expression, const auto& self) -> bool { + using ExpressionKind = bng::ast::ExpressionKind; + if ((expression.kind() == ExpressionKind::Function || + expression.kind() == ExpressionKind::ObservableRef) && + !expression.args().empty()) { + const auto* function = getModelFunction(model, expression.name()); + if (function != nullptr && + function->getArgs().size() == expression.args().size() && + std::all_of( + expression.args().begin(), expression.args().end(), + [&](const auto& argument) { + return argument.kind() == ExpressionKind::Identifier && + hasRateScope(argument.name()); + })) { + return true; + } + } + return std::any_of( + expression.args().begin(), expression.args().end(), + [&](const auto& child) { return self(child, self); }); + }; + // A species-scoped rule may put a local-function call inside + // arithmetic (for example ``kr*(1-pOn(x))``). That is not the + // narrow ``f(x)`` localFunctionRate shape, but the generic dynamic + // adapter can preserve and bind the scoped argument as a DOR rate. + const bool dynamicScopedFunctionAvailable = + !rates.empty() && + hasScopedModelFunctionCall(rates.front(), hasScopedModelFunctionCall); + if ((rule.hasScopePrefix() && !localFunctionRate && !functionProductRate && + !dynamicScopedFunctionAvailable) || (localFunctionRate && !localFunctionScopeAvailable) || (functionProductRate && !functionProductScopeAvailable) || rates.empty() || (reactantPatterns.empty() && productPatterns.empty())) { @@ -4594,7 +4914,15 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, // Null/Trash are NFsim's degradation sentinels, not real molecule // types. The XML loader drops these product entries and keeps the // corresponding DeleteMolecule operation. - if (isDiscardMolecule(productMolecules[productRef.moleculeIndex])) { + const auto& productMolecule = productMolecules[productRef.moleculeIndex]; + bool declaredProductType = false; + for (int index = 0; index < s->getNumOfMoleculeTypes(); ++index) { + if (s->getMoleculeType(index)->getName() == productMolecule.name) { + declaredProductType = true; + break; + } + } + if (isDiscardMolecule(productMolecule) && !declaredProductType) { return true; } if (!addedProductIndexes.emplace(productRef, directProducts.size()).second) { @@ -4622,7 +4950,7 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, } } } - if (ok && reactantPatterns.empty()) { + if (ok && reactantPatterns.empty() && addMoleculeTemplates.empty()) { for (std::size_t patternIndex = 0; patternIndex < productPatterns.size() && ok; ++patternIndex) { const auto molecules = collectGraphMolecules( @@ -4771,7 +5099,7 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, bng::ast::ReactionRule::ComponentRef{ productRef.patternIndex, productRef.moleculeIndex, componentIndex}, std::make_pair(operationProduct.templateMolecule, - graphMolecule.components[componentIndex].name)); + operationProduct.runtimeComponentNames.at(componentIndex))); } } } @@ -4896,6 +5224,12 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, ok = false; break; } + // A product bond to an existing molecule requires the + // reactant site to be available. New-molecule binding uses a + // deferred transform and cannot preflight this condition + // after the product molecule is created, so encode the + // availability on the reactant template itself. + existingTemplate->addEmptyComponent(existingName); if (!transformationSet->addNewMoleculeBindingTransform( existingTemplate, existingName, added->second.first, added->second.second)) { @@ -4986,6 +5320,7 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, std::vector functionProductArguments2; std::size_t functionProductPattern1 = reactantRoots.size(); std::size_t functionProductPattern2 = reactantRoots.size(); + std::size_t localFunctionPattern = reactantRoots.size(); const auto mapFunctionProductArgument = [&](const FunctionProductOperand& operand, std::vector& argumentNames, @@ -5025,6 +5360,54 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, argumentNames.push_back(operand.argument); return true; }; + const auto mapLocalFunctionArgument = [&](const std::string& argumentName) { + std::size_t matchingPattern = reactantRoots.size(); + int matchingScope = LocalFunction::MOLECULE; + for (std::size_t patternIndex = 0; + patternIndex < reactantRoots.size(); ++patternIndex) { + if (patternIndex >= rule.getReactants().size()) continue; + if (!hasScopedArgument( + rule.getReactants()[patternIndex], argumentName)) { + continue; + } + if (matchingPattern != reactantRoots.size()) { + diagnostic = "a local-function scope identifier refers to multiple reactants"; + return false; + } + matchingPattern = patternIndex; + if (hasSpeciesScopedArgument( + rule.getReactants()[patternIndex], argumentName)) { + matchingScope = LocalFunction::SPECIES; + } + } + if (matchingPattern == reactantRoots.size()) { + diagnostic = "local-function scope identifier '" + argumentName + + "' has no matching reactant"; + return false; + } + if (localFunctionPattern == reactantRoots.size()) { + localFunctionPattern = matchingPattern; + } else if (localFunctionPattern != matchingPattern) { + diagnostic = "local-function scope identifiers must refer to one reactant"; + return false; + } + if (reactantRoots[matchingPattern]->getMoleculeType()->isPopulationType()) { + diagnostic = "local functions cannot scope population reactants"; + return false; + } + if (std::find( + localFunctionArgumentNames.begin(), localFunctionArgumentNames.end(), + argumentName) != localFunctionArgumentNames.end()) { + return true; + } + if (!transformationSet->addLocalFunctionReference( + reactantRoots[matchingPattern], argumentName, matchingScope)) { + diagnostic = "could not add local-function scope reference"; + return false; + } + localFunctionArgumentNames.push_back(argumentName); + return true; + }; if (functionProductRate) { ok = mapFunctionProductArgument( functionProductOperand1, functionProductArguments1, @@ -5045,45 +5428,10 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, ok = false; break; } - std::size_t matchingPattern = reactantRoots.size(); - int matchingScope = LocalFunction::MOLECULE; - for (std::size_t patternIndex = 0; - patternIndex < reactantRoots.size(); ++patternIndex) { - if (patternIndex >= rule.getReactants().size()) continue; - if (!hasScopedArgument( - rule.getReactants()[patternIndex], argument.name())) { - continue; - } - if (matchingPattern != reactantRoots.size()) { - diagnostic = "a local-function scope identifier refers to multiple reactants"; - ok = false; - break; - } - matchingPattern = patternIndex; - if (hasSpeciesScopedArgument( - rule.getReactants()[patternIndex], argument.name())) { - matchingScope = LocalFunction::SPECIES; - } - } - if (!ok) break; - if (matchingPattern == reactantRoots.size()) { - diagnostic = "local-function scope identifier '" + argument.name() + - "' has no matching reactant"; - ok = false; - break; - } - if (reactantRoots[matchingPattern]->getMoleculeType()->isPopulationType()) { - diagnostic = "local functions cannot scope population reactants"; + if (!mapLocalFunctionArgument(argument.name())) { ok = false; break; } - if (!transformationSet->addLocalFunctionReference( - reactantRoots[matchingPattern], argument.name(), matchingScope)) { - diagnostic = "could not add local-function scope reference"; - ok = false; - break; - } - localFunctionArgumentNames.push_back(argument.name()); } } if (!ok) { @@ -5095,8 +5443,6 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, return false; } - transformationSet->finalize(); - const auto& rate = rates.front(); ReactionClass* reaction = nullptr; std::string rateParameterName; @@ -5139,6 +5485,7 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, delete transformationSet; return false; } + transformationSet->finalize(); reaction = new MMRxnClass(rule.getRuleName(), kcat, km, transformationSet, s); } else if (saturationRate || hillRate) { if (reactantRoots.empty()) { @@ -5170,9 +5517,11 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, constants.push_back(value); } if (saturationRate) { + transformationSet->finalize(); reaction = new SatRxnClass( rule.getRuleName(), std::move(constants), transformationSet, s); } else { + transformationSet->finalize(); reaction = new HillRxnClass( rule.getRuleName(), constants[0], constants[1], constants[2], transformationSet, s); @@ -5187,6 +5536,7 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, delete transformationSet; return false; } + transformationSet->finalize(); reaction = new DOR2RxnClass( rule.getRuleName(), 1.0, "", transformationSet, composite1, composite2, functionProductArguments1, functionProductArguments2, s); @@ -5198,6 +5548,7 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, delete transformationSet; return false; } + transformationSet->finalize(); reaction = new DORRxnClass( rule.getRuleName(), 1.0, "", transformationSet, composite, localFunctionArgumentNames, s); @@ -5212,6 +5563,7 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, delete transformationSet; return false; } + transformationSet->finalize(); if (global != nullptr) { reaction = new FunctionalRxnClass( rule.getRuleName(), global, transformationSet, s); @@ -5238,27 +5590,46 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, parameters.count(rate.name()) != 0) { rateParameterName = rate.name(); } + transformationSet->finalize(); reaction = new BasicRxnClass( rule.getRuleName(), 0.0, "", transformationSet, s); reaction->setBaseRate(rateValue, rateParameterName); } else { GlobalFunction* dynamicGlobal = nullptr; CompositeFunction* dynamicComposite = nullptr; + std::vector dynamicLocalFunctionArguments; if (!addDynamicReactionRateFunction( rate, model, parameters, s, sourcePath, originalRuleOrdinal * 2 + directionOrdinal, - dynamicGlobal, dynamicComposite, diagnostic)) { + dynamicGlobal, dynamicComposite, dynamicLocalFunctionArguments, + diagnostic)) { std::cerr << "[nfsim/ast] cannot map reaction '" << rule.getRuleName() << "': " << diagnostic << "\n"; delete transformationSet; return false; } + for (const auto& argumentName : dynamicLocalFunctionArguments) { + if (!mapLocalFunctionArgument(argumentName)) { + std::cerr << "[nfsim/ast] cannot map reaction '" + << rule.getRuleName() << "': " << diagnostic << "\n"; + delete transformationSet; + return false; + } + } + transformationSet->finalize(); if (dynamicGlobal != nullptr) { reaction = new FunctionalRxnClass( rule.getRuleName(), dynamicGlobal, transformationSet, s); } else if (dynamicComposite != nullptr) { - reaction = new FunctionalRxnClass( - rule.getRuleName(), dynamicComposite, transformationSet, s); + if (dynamicLocalFunctionArguments.empty()) { + reaction = new FunctionalRxnClass( + rule.getRuleName(), dynamicComposite, transformationSet, s); + } else { + reaction = new DORRxnClass( + rule.getRuleName(), 1.0, "", transformationSet, + dynamicComposite, dynamicLocalFunctionArguments, s); + dynamicComposite->setGlobalObservableDependency(reaction, s); + } } else { std::cerr << "[nfsim/ast] dynamic reaction rate produced no function\n"; delete transformationSet; @@ -5274,6 +5645,66 @@ bool addReactionRulesFromAst(const bng::ast::Model& model, System* s, } } + // Match NFinput::initReactionRules: a zero-order synthesis rate is + // converted from concentration/time to molecule-count/time using the + // product compartment volume and NumberPerQuantityUnit. The factor is + // kept on functional reactions for update_a(); elementary rates must be + // scaled once before they enter the live reaction list. + if (reactantRoots.empty()) { + Compartment* productCompartment = nullptr; + bool allSameCompartment = true; + for (const auto& product : collectRuleGraphMolecules(productPatterns)) { + const auto& molecule = product.molecule; + bool declaredProductType = false; + for (int index = 0; index < s->getNumOfMoleculeTypes(); ++index) { + if (s->getMoleculeType(index)->getName() == molecule.name) { + declaredProductType = true; + break; + } + } + if (isDiscardMolecule(molecule) && !declaredProductType) { + continue; + } + + const std::string compartmentName = graphMoleculeCompartment( + productPatterns[product.patternIndex], molecule); + if (compartmentName.empty()) continue; + Compartment* compartment = nullptr; + if (!resolveCompartment(s, compartmentName, compartment, diagnostic)) { + std::cerr << "[nfsim/ast] cannot map reaction '" + << rule.getRuleName() << "': " << diagnostic << "\n"; + delete reaction; + delete transformationSet; + cleanupDirectProducts(); + return false; + } + if (productCompartment == nullptr) { + productCompartment = compartment; + } else if (productCompartment != compartment) { + allSameCompartment = false; + std::cerr << "[nfsim/ast] warning: zero-order synthesis ('" + << rule.getRuleName() + << "') has products in different compartments; " + "volume scaling may be incorrect\n"; + break; + } + } + + double volumeConversion = 1.0; + if (productCompartment != nullptr && allSameCompartment) { + volumeConversion = productCompartment->getSize(); + const double numberPerQuantity = s->getNumberPerQuantityUnit(); + if (numberPerQuantity > 0.0) { + volumeConversion *= numberPerQuantity; + } + } + reaction->volumeConversionFactor = volumeConversion; + if (reaction->getRxnType() != ReactionClass::OBS_DEPENDENT_RXN) { + reaction->setBaseRate( + reaction->getBaseRate() * volumeConversion, ""); + } + } + // Keep zero-rate elementary rules out of the live reaction list, as the // XML loader does. Functional reactions retain their dynamic function. if (reaction->getRxnType() == ReactionClass::BASIC_RXN && @@ -5299,6 +5730,38 @@ System* buildSystemFromAst(const bng::ast::Model& model, bool verbose, int& suggestedTraversalLimit, const std::filesystem::path& sourcePath) { + // Preserve the historical API contract: before the direct adapter grew a + // separate complex-bookkeeping flag, this argument controlled both the + // System constructor and same-complex binding checks. + static const SeedAmountOverrides noOverrides; + return buildSystemFromAstWithSeedOverrides( + model, blockSameComplexBinding, blockSameComplexBinding, + globalMoleculeLimit, verbose, suggestedTraversalLimit, sourcePath, + noOverrides); +} + +System* buildSystemFromAst(const bng::ast::Model& model, + bool useComplex, + bool blockSameComplexBinding, + int globalMoleculeLimit, + bool verbose, + int& suggestedTraversalLimit, + const std::filesystem::path& sourcePath) { + static const SeedAmountOverrides noOverrides; + return buildSystemFromAstWithSeedOverrides( + model, useComplex, blockSameComplexBinding, globalMoleculeLimit, + verbose, suggestedTraversalLimit, sourcePath, noOverrides); +} + +System* buildSystemFromAstWithSeedOverrides( + const bng::ast::Model& model, + bool useComplex, + bool blockSameComplexBinding, + int globalMoleculeLimit, + bool verbose, + int& suggestedTraversalLimit, + const std::filesystem::path& sourcePath, + const SeedAmountOverrides& seedAmountOverrides) { // Migration escape hatch used by the parity gate: force the XML path. if (std::getenv("BNG_NFSIM_FORCE_XML")) { if (verbose) std::cerr << "[nfsim/ast] BNG_NFSIM_FORCE_XML set -> XML path\n"; @@ -5307,7 +5770,7 @@ System* buildSystemFromAst(const bng::ast::Model& model, const std::string& name = model.getModelName(); System* s = new System(name.empty() ? "model" : name, - blockSameComplexBinding, globalMoleculeLimit); + useComplex, globalMoleculeLimit); // The CLI enables complex-scoped local functions by default. The direct // AST entry point has no separate -nocslf argument, so preserve that // default explicitly instead of leaving the legacy System flag unset. @@ -5329,7 +5792,8 @@ System* buildSystemFromAst(const bng::ast::Model& model, addObservablesFromAst(model, s, parameters, verbose, suggestedTraversalLimit) && addFunctionsFromAst(model, s, parameters, verbose, sourcePath) && addEnergyPatternsFromAst(model, s, parameters, verbose) && - addSpeciesFromAst(model, s, parameters, verbose) && + addSpeciesFromAstWithOverrides( + model, s, parameters, verbose, seedAmountOverrides) && addReactionRulesFromAst(model, s, parameters, blockSameComplexBinding, verbose, suggestedTraversalLimit, sourcePath); } catch (const std::exception& error) { diff --git a/cpp/nfsim/NFinput/NFinput_fromAst.hh b/cpp/nfsim/NFinput/NFinput_fromAst.hh index e84546d8..7ee2fa10 100644 --- a/cpp/nfsim/NFinput/NFinput_fromAst.hh +++ b/cpp/nfsim/NFinput/NFinput_fromAst.hh @@ -17,8 +17,9 @@ /// /// MIGRATION CONTRACT (how the gate proves correctness): /// - bind_nfsim routes through buildSystemFromAst() by default. -/// - Setting the env var BNG_NFSIM_FORCE_XML=1 forces the old in-memory-XML -/// path (initializeFromModel). The harness test +/// - Setting BNG_NFSIM_FORCE_XML=1 forces the old in-memory-XML path +/// (initializeFromModel), and BNG_NFSIM_ALLOW_XML_FALLBACK=1 explicitly +/// permits that compatibility path. The harness test /// test_parity_nfsim::test_nf_ast_direct_matches_xml runs the model both /// ways under one fixed seed and requires identical trajectories. The /// in-memory-XML path is therefore the behavioral oracle for the direct @@ -38,6 +39,8 @@ namespace NFcore { class System; } namespace NFinput { +using SeedAmountOverrides = std::map; + /// Build an NFcore::System directly from a parsed ast::Model. /// /// @param model fully-constructed model (network-free target). @@ -46,15 +49,44 @@ namespace NFinput { /// @param verbose progress messages. /// @param suggestedTraversalLimit out: recommended traversal depth. /// @param sourcePath optional BNGL source path for relative TFUN files. -/// @return owned System, or nullptr on error (caller may fall back to XML). +/// @return owned System, or nullptr on error. Callers must make any XML +/// compatibility fallback explicit to the user. +NFcore::System* buildSystemFromAst( + const bng::ast::Model& model, + bool blockSameComplexBinding, + int globalMoleculeLimit, + bool verbose, + int& suggestedTraversalLimit, + const std::filesystem::path& sourcePath = {}); + +/// Build an NFcore::System with the native NFsim complex flags separated. +/// +/// Native NFsim uses ``-cb`` to maintain complex identities and ``-bscb`` to +/// reject bindings whose reactants already share a complex. The historical +/// five-argument API above predates that distinction and remains as a +/// compatibility wrapper where its boolean means both behaviors. NFcore::System* buildSystemFromAst( const bng::ast::Model& model, + bool useComplex, bool blockSameComplexBinding, int globalMoleculeLimit, bool verbose, int& suggestedTraversalLimit, const std::filesystem::path& sourcePath = {}); +/// Build directly while replacing matching seed amounts with current action +/// state. Action dispatch uses this for setConcentration/addConcentration +/// before an NFsim run; the model's original seed expressions remain unchanged. +NFcore::System* buildSystemFromAstWithSeedOverrides( + const bng::ast::Model& model, + bool useComplex, + bool blockSameComplexBinding, + int globalMoleculeLimit, + bool verbose, + int& suggestedTraversalLimit, + const std::filesystem::path& sourcePath, + const SeedAmountOverrides& seedAmountOverrides); + // --- Per-section direct builders (mirror the TiXml-based init* functions) --- // Each returns false on error. Implement incrementally; until a builder is // done, buildSystemFromAst delegates that section to the in-memory-XML path. diff --git a/cpp/nfsim/NFreactions/reactantLists/reactantList.cpp b/cpp/nfsim/NFreactions/reactantLists/reactantList.cpp index b3078ce2..13ce32d2 100644 --- a/cpp/nfsim/NFreactions/reactantLists/reactantList.cpp +++ b/cpp/nfsim/NFreactions/reactantLists/reactantList.cpp @@ -5,6 +5,7 @@ using namespace NFcore; ReactantList::ReactantList(unsigned int reactantIndex, TransformationSet *ts, unsigned int init_capacity=50) { + this->anyMultiMoleculeComplex = false; this->n_mappingSets = 0; this->capacity = init_capacity; diff --git a/cpp/nfsim/NFreactions/reactantLists/reactantList.hh b/cpp/nfsim/NFreactions/reactantLists/reactantList.hh index f8dce9a6..80f49c4b 100644 --- a/cpp/nfsim/NFreactions/reactantLists/reactantList.hh +++ b/cpp/nfsim/NFreactions/reactantLists/reactantList.hh @@ -55,6 +55,11 @@ namespace NFcore */ virtual int size() const { return n_mappingSets; }; + void noteMappedComplexSize(int complexSize) { + if (complexSize > 1) anyMultiMoleculeComplex = true; + } + bool mayShareComplexes() const { return anyMultiMoleculeComplex; } + /*! Returns the sum population of all mappingSets that have been added to this list */ @@ -117,6 +122,7 @@ namespace NFcore /*! Maintains the number of mappingSets on this list */ int n_mappingSets; + bool anyMultiMoleculeComplex; /*! The total capacity that this list can hold */ int capacity; diff --git a/cpp/nfsim/NFreactions/reactantLists/reactantTree.cpp b/cpp/nfsim/NFreactions/reactantLists/reactantTree.cpp index 5a016089..48ae31c7 100644 --- a/cpp/nfsim/NFreactions/reactantLists/reactantTree.cpp +++ b/cpp/nfsim/NFreactions/reactantLists/reactantTree.cpp @@ -18,6 +18,7 @@ ReactantTree::ReactantTree( TransformationSet *ts, unsigned int init_capacity) { + this->anyMultiMoleculeComplex = false; //cout<<"Creating reactant tree... "<reactantIndex=reactantIndex; this->ts=ts; - //set the initial size of the tree - if(init_capacity<4) maxElementCount=4; - else maxElementCount = init_capacity; + //set the initial size of the tree. Compact EnergyPattern reactions have a + //single weighted mapping in the common case; let them start with one leaf + //and expand on demand. Preserve the historical minimum for other callers. + if (init_capacity == 1) + maxElementCount = 1; + else if(init_capacity<4) + maxElementCount = 4; + else + maxElementCount = init_capacity; //Get the depth of the tree, (can cast here because depth will always be a small integer) this->treeDepth = (unsigned int)ceil((double)log((double)maxElementCount)/(double)log((double)2)) ; @@ -77,6 +84,9 @@ ReactantTree::ReactantTree( this->n_mappingSets = 0; + this->singleMappingFastPath = false; + this->singleMappingId = 0; + this->singleMappingRateFactor = 0.0; //cout<<"so setting a limit of " << this->maxElementCount <<" molecules. "<= maxElementCount) { //cout<<"-------------\nIn ReactantTree!!! Adding more than I can take, so I'm expanding! "<removeMappingSet(clone); } + refreshSingleMappingFastPath(); } @@ -337,6 +361,10 @@ void ReactantTree::removeMappingSet(unsigned int mappingSetId) cerr<<"Trying to remove from an empty ReactantTree!!"<removeMappingSet(clone); } + refreshSingleMappingFastPath(); } @@ -411,6 +440,11 @@ void ReactantTree::pickReactantFromValue(MappingSet *&ms, double value, double b cerr<<" rateFactorSum: " << leftRateFactorSum[0] << " and total " << (leftRateFactorSum[0]*baseRate) << endl; } + if (singleMappingFastPath) { + ms = mappingSets[msPositionMap[singleMappingId]]; + return; + } + //Start from the top of the tree, and based on the given value, determine //where we should end up... unsigned int cn = 1; // index of current node @@ -459,8 +493,21 @@ void ReactantTree::pickReactantFromValue(MappingSet *&ms, double value, double b -void ReactantTree::updateValue(unsigned int mappingSetId, double newRateFactor) +bool ReactantTree::updateValue(unsigned int mappingSetId, double newRateFactor) { + if (singleMappingFastPath && mappingSetId == singleMappingId) { + if (singleMappingRateFactor == newRateFactor) return false; + singleMappingRateFactor = newRateFactor; + + // Keep the leaf and total current so a later insertion can materialize + // the full tree without losing this update. + unsigned int treeIndex = msTreePositionMap[mappingSetId]; + unsigned int cn = treeIndex + this->maxElementCount; + leftRateFactorSum[cn] = newRateFactor; + leftRateFactorSum[0] = newRateFactor; + return true; + } + //Here we start from the bottom, removing the old value and adding the new value //Go to that position in the tree, and work up and out @@ -481,7 +528,7 @@ void ReactantTree::updateValue(unsigned int mappingSetId, double newRateFactor) double oldRateFactor = leftRateFactorSum[cn]; //Make sure there is something to change! If not, just get out of here! - if(oldRateFactor==newRateFactor) return; + if(oldRateFactor==newRateFactor) return false; //cout<<"Updating value from: "<(treeIndex) + + this->maxElementCount; + leftRateFactorSum[cn] = singleMappingRateFactor; + while(cn > 1) + { + unsigned int parent = cn / 2; + leftRateFactorSum[parent] = (cn % 2 == 0) + ? singleMappingRateFactor : 0.0; + cn = parent; + } + leftRateFactorSum[0] = singleMappingRateFactor; +} + + +void ReactantTree::refreshSingleMappingFastPath() +{ + singleMappingFastPath = false; + if (n_mappingSets != 1) return; + + MappingSet *only = mappingSets[0]; + if (only == 0 || only->getClonedMapping() != MappingSet::NO_CLONE) + return; + + int treeIndex = msTreePositionMap[only->getId()]; + if (treeIndex < 0) return; + + singleMappingId = only->getId(); + singleMappingRateFactor = leftRateFactorSum[ + static_cast(treeIndex) + this->maxElementCount]; + singleMappingFastPath = true; +} + + void ReactantTree::printDetails() const { cout<getId(); unsigned int treeIndex = msTreePositionMap[mappingSetId]; unsigned int cn = treeIndex + this->maxElementCount; diff --git a/cpp/nfsim/NFreactions/reactantLists/reactantTree.hh b/cpp/nfsim/NFreactions/reactantLists/reactantTree.hh index 8855e7fa..9bebe7f8 100644 --- a/cpp/nfsim/NFreactions/reactantLists/reactantTree.hh +++ b/cpp/nfsim/NFreactions/reactantLists/reactantTree.hh @@ -95,7 +95,7 @@ namespace NFcore When a local function value changes, it must update the value in the reactant tree. This method allows you to update values without changing the mappingSet membership of this tree. */ - void updateValue(unsigned int mappingSetId, double newRateFactor); + bool updateValue(unsigned int mappingSetId, double newRateFactor); /*! Returns a MappingSet so that a DOR can evaluate a local function on it. @@ -104,6 +104,10 @@ namespace NFcore MappingSet * getMappingSetByIndex(unsigned int index) const { return index < static_cast(n_mappingSets) ? mappingSets[index] : 0; } + void noteMappedComplexSize(int complexSize) { + if (complexSize > 1) anyMultiMoleculeComplex = true; + } + bool mayShareComplexes() const { return anyMultiMoleculeComplex; } @@ -120,7 +124,10 @@ namespace NFcore Returns the combined rate factor sum of this tree, which is needed by the DOR reactionclass in order to properly update its propensity */ - double getRateFactorSum() const { return leftRateFactorSum[0]; }; + double getRateFactorSum() const { + return singleMappingFastPath + ? singleMappingRateFactor : leftRateFactorSum[0]; + }; double getRateFactor(int mappingSetArrayIndex) const; @@ -154,6 +161,7 @@ namespace NFcore unsigned int navigateAndInsertTree(unsigned int firstTreeIndex, int* lElementCount, int* rElementCount, double* lRateFactorSum, double rateFactor); + bool anyMultiMoleculeComplex; TransformationSet *ts; //Keeps track of the set of transformations unsigned int reactantIndex; //the index of the tree @@ -197,6 +205,17 @@ namespace NFcore //The index of the first molecule in the tree unsigned int firstMappingTreeIndex; + + /* Compact EnergyPattern reactions commonly have one weighted mapping. + * Keep that rate in a scalar so selection and updates avoid walking the + * otherwise-required binary tree. A second mapping materializes the + * ordinary tree representation. */ + bool singleMappingFastPath; + unsigned int singleMappingId; + double singleMappingRateFactor; + + void materializeSingleMappingTree(); + void refreshSingleMappingFastPath(); }; } diff --git a/cpp/nfsim/NFreactions/reactions/DORreaction.cpp b/cpp/nfsim/NFreactions/reactions/DORreaction.cpp index b9e5e75b..23738f12 100644 --- a/cpp/nfsim/NFreactions/reactions/DORreaction.cpp +++ b/cpp/nfsim/NFreactions/reactions/DORreaction.cpp @@ -3,6 +3,8 @@ #include "reaction.hh" +#include + #define DEBUG_MESSAGE 0 @@ -176,6 +178,93 @@ DORRxnClass::DORRxnClass( cf->addTypeIMoleculeDependency( reactantTemplates[DORreactantIndex]->getMoleculeType() ); } + +DORRxnClass::DORRxnClass( + string name, + double baseRate, + string baseRateName, + TransformationSet *transformationSet, + int dorReactantIndex, + System *s) : + ReactionClass(name,baseRate,baseRateName,transformationSet,s), + cf(0), + DORreactantIndex(dorReactantIndex), + n_argMolecules(0), + argIndexIntoMappingSet(0), + argMappedMolecule(0), + argScope(0) +{ + /* This constructor is deliberately limited to the same single weighted + * reactant model used by ordinary DOR reactions. EnergyRxnClass uses it + * only for two-reactant binding rules and one-reactant reverse rules. */ + if (dorReactantIndex < 0 || dorReactantIndex >= (int)n_reactants) { + cerr << "Invalid weighted reactant index when creating DOR reaction: " + << name << endl; + exit(1); + } + if (transformationSet->getTemplateMolecule((unsigned)dorReactantIndex) + ->getMoleculeType()->isPopulationType()) { + cerr << "A weighted DOR reactant cannot be a population type: " + << name << endl; + exit(1); + } + + reactionType = ReactionClass::DOR_RXN; + reactantTree = new ReactantTree(dorReactantIndex,transformationSet,32); + msPairBuffer = new MappingSet *[n_reactants > 2 ? n_reactants : 2]; + reactantLists = new ReactantList *[n_reactants]; + for (unsigned int r=0; r= (int)n_reactants) { + cerr << "Invalid weighted reactant index when creating DOR reaction: " + << name << endl; + exit(1); + } + if (transformationSet->getTemplateMolecule((unsigned)dorReactantIndex) + ->getMoleculeType()->isPopulationType()) { + cerr << "A weighted DOR reactant cannot be a population type: " + << name << endl; + exit(1); + } + + reactionType = ReactionClass::DOR_RXN; + reactantTree = new ReactantTree( + dorReactantIndex, transformationSet, reactantTreeInitialCapacity); + msPairBuffer = new MappingSet *[n_reactants > 2 ? n_reactants : 2]; + reactantLists = new ReactantList *[n_reactants]; + for (unsigned int r=0; r tempSet = m->getRxnListMappingSet(rxnIndex); - for(set::iterator it= tempSet.begin();it!= tempSet.end(); ++it){ + const MappingIdSet& tempSet = m->getRxnListMappingSet(rxnIndex); + for(MappingIdSet::const_iterator it= tempSet.begin();it!= tempSet.end(); ++it){ MappingSet* ms2 = reactantTree->getMappingSet(*it); if(MappingSet::checkForEquality(ms,ms2)){ return *it; @@ -250,6 +339,12 @@ int DORRxnClass::checkForCollision(Molecule *m, MappingSet* ms, int rxnIndex){ bool DORRxnClass::tryToAdd(Molecule *m, unsigned int reactantPos) { if(DEBUG_MESSAGE)cout<printDetails(); + if (contextCountsPerComplex[reactantPos] && m->getComplex() != 0) { + if (reactantPos == (unsigned)DORreactantIndex) + reactantTree->noteMappedComplexSize(m->getComplex()->getComplexSize()); + else + reactantLists[reactantPos]->noteMappedComplexSize(m->getComplex()->getComplexSize()); + } if(reactantPos==(unsigned)this->DORreactantIndex) { if(DEBUG_MESSAGE)cout<<" ... as a DOR "<name<getRxnListMappingId(m->getMoleculeType()->getRxnIndex(this,reactantPos))< deleteMs = m->getRxnListMappingSet(rxnIndex); + MappingIdSet deleteMs = m->getRxnListMappingSet(rxnIndex); symmetricMappingSet.clear(); if(m->getRxnListMappingId(rxnIndex)>=0) { /* JJT: this branch contains those reactions for which a reaction and a molecule had been mapped together before @@ -340,7 +435,7 @@ bool DORRxnClass::tryToAdd(Molecule *m, unsigned int reactantPos) { } - for(set::iterator it=deleteMs.begin();it!=deleteMs.end(); ++it){ + for(MappingIdSet::iterator it=deleteMs.begin();it!=deleteMs.end(); ++it){ if(DEBUG_MESSAGE)cout<<"removing..."<<*it<deleteRxnListMappingId(rxnIndex,*it); reactantTree->removeMappingSet(*it); @@ -494,23 +589,15 @@ int DORRxnClass::getReactantCount(unsigned int reactantIndex) const int DORRxnClass::getCorrectedReactantCount(unsigned int reactantIndex) const { if(reactantIndex==(unsigned)this->DORreactantIndex) { - return reactantTree->size(); + return contextCountsPerComplex[reactantIndex] + ? countDistinctComplexes(reactantTree) + : reactantTree->size(); } - if (matchOncePerReactant[reactantIndex] && !isPopulationType[reactantIndex]) { - std::set uniqueComplexes; - ReactantList *rl = reactantLists[reactantIndex]; - int size = rl->size(); - for (int i = 0; i < size; ++i) { - MappingSet *ms = rl->getMappingSetByIndex(i); - if (ms && ms->getNumOfMappings() > 0) { - Mapping *mapping = ms->get(0); - if (mapping && mapping->getMolecule()) { - uniqueComplexes.insert(mapping->getMolecule()->getComplexID()); - } - } - } - return (int)uniqueComplexes.size(); + if ((matchOncePerReactant[reactantIndex] || + contextCountsPerComplex[reactantIndex]) && + !isPopulationType[reactantIndex]) { + return countDistinctComplexes(reactantLists[reactantIndex]); } return isPopulationType[reactantIndex] ? @@ -623,7 +710,9 @@ double DORRxnClass::update_a() { if(i!=DORreactantIndex) { a*=(double)getCorrectedReactantCount(i); } else { - a*=reactantTree->getRateFactorSum(); + a *= contextCountsPerComplex[i] + ? perComplexRateFactorSum(reactantTree) + : reactantTree->getRateFactorSum(); } } return a; @@ -660,35 +749,41 @@ double DORRxnClass::exactRuleMonkey_a() validCombinations = 1.0; } else if (n_reactants == 1) { if (0 == DORreactantIndex) { - validCombinations = reactantTree->getRateFactorSum(); + validCombinations = contextCountsPerComplex[0] + ? perComplexRateFactorSum(reactantTree) + : reactantTree->getRateFactorSum(); } else { validCombinations = getCorrectedReactantCount(0); } } else if (n_reactants == 2) { - int size0 = getReactantCount(0); - int size1 = getReactantCount(1); + static thread_local vector reps0, reps1; + static thread_local vector idx0, idx1; + collectReactantRepresentatives(reactantLists[0], contextCountsPerComplex[0], reps0, &idx0); + collectReactantRepresentatives(reactantLists[1], contextCountsPerComplex[1], reps1, &idx1); double totalCombinations = 1.0; for(unsigned int i=0; igetRateFactorSum(); + totalCombinations *= contextCountsPerComplex[i] + ? perComplexRateFactorSum(reactantTree) + : reactantTree->getRateFactorSum(); } } double invalidCombinations = 0; - for (int i = 0; i < size0; ++i) { - msPairBuffer[0] = reactantLists[0]->getMappingSet(i); - for (int j = 0; j < size1; ++j) { - msPairBuffer[1] = reactantLists[1]->getMappingSet(j); + for (size_t i = 0; i < reps0.size(); ++i) { + msPairBuffer[0] = reps0[i]; + for (size_t j = 0; j < reps1.size(); ++j) { + msPairBuffer[1] = reps1[j]; if (!transformationSet->checkMolecularity(msPairBuffer)) { - double weight = 1.0; + double weight = 1.0; if (0 == DORreactantIndex) { - weight = reactantTree->getRateFactor(i); + weight = reactantTree->getRateFactor(idx0[i]); } else if (1 == DORreactantIndex) { - weight = reactantTree->getRateFactor(j); + weight = reactantTree->getRateFactor(idx1[j]); } invalidCombinations += weight; } @@ -702,7 +797,9 @@ double DORRxnClass::exactRuleMonkey_a() if(i!=DORreactantIndex) { validCombinations*=(double)getCorrectedReactantCount(i); } else { - validCombinations*=reactantTree->getRateFactorSum(); + validCombinations *= contextCountsPerComplex[i] + ? perComplexRateFactorSum(reactantTree) + : reactantTree->getRateFactorSum(); } } } @@ -717,9 +814,9 @@ void DORRxnClass::pickRuleMonkeyMappingSets(double random_A_number) const for(unsigned int i=0; ipickRandomFromPopulation(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandomFromPopulation(mappingSet[i], system->getMappingRNG()); } else { - reactantLists[i]->pickRandom(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandom(mappingSet[i], system->getMappingRNG()); } rateFactorMultiplier*=getReactantCount(i); } @@ -764,9 +861,9 @@ void DORRxnClass::pickRuleMonkeyMappingSets(double random_A_number) const for(unsigned int i=0; ipickRandomFromPopulation(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandomFromPopulation(mappingSet[i], system->getMappingRNG()); } else { - reactantLists[i]->pickRandom(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandom(mappingSet[i], system->getMappingRNG()); } rateFactorMultiplier*=getReactantCount(i); } @@ -809,9 +906,9 @@ void DORRxnClass::pickMappingSets(double randNumber) const for(unsigned int i=0; ipickRandomFromPopulation(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandomFromPopulation(mappingSet[i], system->getMappingRNG()); } else { - reactantLists[i]->pickRandom(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandom(mappingSet[i], system->getMappingRNG()); } rateFactorMultiplier*=getReactantCount(i); } @@ -869,6 +966,865 @@ void DORRxnClass::printDetails() const +/* + * Compact Arrhenius energy reactions + */ + +EnergyRxnClass::EnergyRxnClass( + string name, + double baseRate, + string baseRateName, + TransformationSet *transformationSet, + int dorReactantIndex, + const EnergyBindingContext &context, + double phi, + double RT, + bool isForward, + System *s) : + DORRxnClass(name,baseRate,baseRateName,transformationSet,dorReactantIndex, + s,1,1,false), + conditionalTerms(context.conditionalTerms), + conditionalComponentMasks(), + componentMaskFastPath(true), + simpleMembership(false), + compactFactorizedPropensity(false), + compactForwardPartnerPropensity(false), + compactReversePropensity(false), + preFireBindingFastPath(false), + reactionCenterComponentIndex(-1), + partnerComponentIndex(-1), + partnerMoleculeType(0), + partnerPool(0), + compactPartnerMappingSet(0), + compactRateFactor(0.0), + baseEnergy(context.baseEnergy), + phi(phi), + RT(RT), + isForward(isForward), + weightedDependencyMask(0), + dependencyMaskValid(true), + singleConditionalTermFastPath(false), + baseEnergyRateFactor(0.0), + conditionedEnergyRateFactor(0.0), + multiConditionalTermFastPath(false), + conditionalRateFactors(), + minimumConditionalBits(0) +{ + /* The compact input path currently supplies contexts on the first + * reaction-center molecule. Its mapping is the first mapping in both the + * forward two-reactant rule and the reverse connected rule. */ + if (dorReactantIndex != 0 || context.conditions.empty()) { + cerr << "Invalid compact energy reaction context for " << name << endl; + exit(1); + } + + MoleculeType *weightedType = reactantTemplates[dorReactantIndex]->getMoleculeType(); + if (weightedType->getName() != context.conditions.front().molType) { + cerr << "Compact energy reaction weighted template does not match its " + << "context molecule type for " << name << endl; + exit(1); + } + for (const auto &condition : context.conditions) { + if (condition.reactantIdx != 0) { + cerr << "Compact energy reaction context is not on reactant 0 for " + << name << endl; + exit(1); + } + int componentIndex = weightedType->getCompIndexFromName(condition.compName); + if (componentIndex < 0) { + cerr << "Compact energy reaction context component is not present in " + << weightedType->getName() << ": " << condition.compName << endl; + exit(1); + } + conditionComponentIndices.push_back(componentIndex); + } + for (unsigned int ti = 0; ti < conditionalTerms.size(); ++ti) { + std::uint64_t componentMask = 0; + for (unsigned int ci = 0; ci < conditionComponentIndices.size(); ++ci) { + if ((conditionalTerms[ti].conditionMask & + (std::uint64_t(1) << ci)) == 0) + continue; + int componentIndex = conditionComponentIndices[ci]; + if (componentIndex < 0 || componentIndex >= 64) { + componentMaskFastPath = false; + break; + } + componentMask |= + (std::uint64_t(1) << static_cast(componentIndex)); + } + conditionalComponentMasks.push_back(componentMask); + } + if (componentMaskFastPath && !conditionalComponentMasks.empty()) { + minimumConditionalBits = 64; + for (vector::const_iterator it = + conditionalComponentMasks.begin(); + it != conditionalComponentMasks.end(); ++it) { + std::uint64_t mask = *it; + unsigned int bits = 0; + while (mask != 0) { + mask &= (mask - 1); + ++bits; + } + if (bits < minimumConditionalBits) + minimumConditionalBits = bits; + } + if (minimumConditionalBits == 64) + minimumConditionalBits = 0; + } + + /* Simple factorized binding contexts have one reaction-center constraint on + * the weighted molecule and one empty-site constraint on an ordinary partner. + * They can share a compact partner pool while all other contexts retain the + * ordinary DOR lists. */ + if (transformationSet->getNumOfTransformations(0) > 0) { + Transformation *center = transformationSet->getTransformation(0, 0); + reactionCenterComponentIndex = center->getComponentIndex(); + if (isForward && n_reactants == 2 && + transformationSet->getNumOfTransformations(1) == 1 && + center->getType() == TransformationFactory::BINDING) { + Transformation *partner = transformationSet->getTransformation(1, 0); + partnerComponentIndex = partner->getComponentIndex(); + partnerMoleculeType = reactantTemplates[1]->getMoleculeType(); + simpleMembership = + partner->getType() == TransformationFactory::EMPTY && + !partnerMoleculeType->isPopulationType() && + reactantTemplates[0]->getN_symComps() == 0 && + reactantTemplates[0]->getN_connectedTo() == 0 && + reactantTemplates[1]->getN_symComps() == 0 && + reactantTemplates[1]->getN_connectedTo() == 0; + } else if (!isForward && n_reactants == 1 && + transformationSet->getNumOfTransformations(0) == 2 && + center->getType() == TransformationFactory::UNBINDING) { + Transformation *partner = transformationSet->getTransformation(0, 1); + partnerComponentIndex = partner->getComponentIndex(); + TemplateMolecule *partnerTemplate = partner->getTemplateMolecule(); + partnerMoleculeType = partnerTemplate->getMoleculeType(); + simpleMembership = + partner->getType() == TransformationFactory::EMPTY && + reactantTemplates[0]->getN_symComps() == 0 && + reactantTemplates[0]->getN_connectedTo() == 0 && + partnerTemplate->getN_symComps() == 0 && + partnerTemplate->getN_connectedTo() == 0; + } + } + preFireBindingFastPath = simpleMembership && isForward && + n_reactants == 2 && + transformationSet->getNumOfTransformations(0) == 1 && + transformationSet->getNumOfTransformations(1) == 1; + + if (simpleMembership && isForward && n_reactants == 2) { + partnerPool = partnerMoleculeType->getOrCreateCompactPartnerPool( + partnerComponentIndex); + compactPartnerMappingSet = transformationSet->generateBlankMappingSet(1, 0); + } + compactFactorizedPropensity = simpleMembership && + !matchOncePerReactant[DORreactantIndex]; + compactForwardPartnerPropensity = compactFactorizedPropensity && + isForward && n_reactants == 2 && DORreactantIndex == 0 && + partnerPool != 0 && !matchOncePerReactant[1]; + compactReversePropensity = compactFactorizedPropensity && + !isForward && n_reactants == 1 && DORreactantIndex == 0; + if (simpleMembership) { + if (reactionCenterComponentIndex < 0 || + reactionCenterComponentIndex >= 64) { + dependencyMaskValid = false; + } else { + weightedDependencyMask |= + (std::uint64_t(1) << reactionCenterComponentIndex); + } + for (vector::const_iterator it = conditionComponentIndices.begin(); + it != conditionComponentIndices.end(); ++it) { + if (*it < 0 || *it >= 64) { + dependencyMaskValid = false; + } else { + weightedDependencyMask |= + (std::uint64_t(1) << static_cast(*it)); + } + } + } + + if (!simpleMembership && n_reactants > 1 && reactantLists[1] == 0) { + /* Unsupported contexts still need the ordinary partner list because the + * compact path is deliberately conservative. */ + reactantLists[1] = new ReactantList(1, transformationSet, 25); + } + + /* Cache the small Arrhenius factor table once. Membership updates still + * perform the same occupancy-mask tests, but avoid recomputing exponentials + * for every weighted molecule. */ + if (componentMaskFastPath && conditionalTerms.size() == 1) { + double energyCoefficient = isForward ? phi : (phi - 1.0); + baseEnergyRateFactor = std::exp( + -(energyCoefficient * baseEnergy) / RT); + conditionedEnergyRateFactor = std::exp( + -(energyCoefficient * + (baseEnergy + conditionalTerms[0].energyValue)) / RT); + singleConditionalTermFastPath = true; + } else if (componentMaskFastPath && conditionalTerms.size() > 1 && + conditionalTerms.size() <= 8) { + double energyCoefficient = isForward ? phi : (phi - 1.0); + unsigned int combinationCount = + 1u << static_cast(conditionalTerms.size()); + conditionalRateFactors.resize(combinationCount); + for (unsigned int combination = 0; + combination < combinationCount; ++combination) { + double deltaG = baseEnergy; + for (unsigned int ti = 0; ti < conditionalTerms.size(); ++ti) { + if (combination & (1u << ti)) + deltaG += conditionalTerms[ti].energyValue; + } + conditionalRateFactors[combination] = std::exp( + -(energyCoefficient * deltaG) / RT); + } + multiConditionalTermFastPath = true; + } +} + +EnergyRxnClass::~EnergyRxnClass() +{ + delete compactPartnerMappingSet; + compactPartnerMappingSet = 0; +} + +void EnergyRxnClass::refreshCompactRateFactor() +{ + if (compactFactorizedPropensity) + compactRateFactor = reactantTree->getRateFactorSum(); +} + +bool EnergyRxnClass::getIncrementalMembershipChange( + IncrementalMembershipChange &change) const +{ + if (!simpleMembership) + return false; + change.moleculeType1 = reactantTemplates[0]->getMoleculeType(); + change.componentIndex1 = reactionCenterComponentIndex; + change.isBoundAfter1 = isForward; + change.moleculeType2 = partnerMoleculeType; + change.componentIndex2 = partnerComponentIndex; + change.isBoundAfter2 = isForward; + return true; +} + +bool EnergyRxnClass::getCompactMembershipIndexInfo( + unsigned int reactantPos, + int &reactionCenterComponent, + std::uint64_t &contextComponentMask, + unsigned int &minimumContextComponents) const +{ + if (!simpleMembership || + reactantPos != static_cast(DORreactantIndex) || + !componentMaskFastPath || reactionCenterComponentIndex < 0) + return false; + contextComponentMask = 0; + for (vector::const_iterator it = conditionComponentIndices.begin(); + it != conditionComponentIndices.end(); ++it) { + if (*it < 0 || *it >= 64) + return false; + contextComponentMask |= + (std::uint64_t(1) << static_cast(*it)); + } + minimumContextComponents = minimumConditionalBits; + reactionCenterComponent = reactionCenterComponentIndex; + return true; +} + +bool EnergyRxnClass::dependsOnEndpoint( + MoleculeType *targetMoleculeType, + MoleculeType *changedMoleculeType, + int changedComponentIndex) const +{ + MoleculeType *weightedType = reactantTemplates[0]->getMoleculeType(); + if (targetMoleculeType == weightedType && + changedMoleculeType == weightedType) { + if (dependencyMaskValid && changedComponentIndex >= 0 && + changedComponentIndex < 64) { + return (weightedDependencyMask & + (std::uint64_t(1) << changedComponentIndex)) != 0; + } + if (changedComponentIndex == reactionCenterComponentIndex) + return true; + for (vector::const_iterator it = conditionComponentIndices.begin(); + it != conditionComponentIndices.end(); ++it) { + if (changedComponentIndex == *it) + return true; + } + } + + return targetMoleculeType == partnerMoleculeType && + changedMoleculeType == partnerMoleculeType && + changedComponentIndex == partnerComponentIndex; +} + +bool EnergyRxnClass::shouldUpdateMembership( + Molecule *m, ReactionClass *firedReaction, bool directProduct) const +{ + if (!simpleMembership || firedReaction == 0 || + !firedReaction->usesIncrementalMembership()) + return true; + + IncrementalMembershipChange firedChange; + if (!firedReaction->getIncrementalMembershipChange(firedChange)) + return true; + if (!directProduct) + return false; + + MoleculeType *targetMoleculeType = m->getMoleculeType(); + if (dependsOnEndpoint(targetMoleculeType, firedChange.moleculeType1, + firedChange.componentIndex1)) + return true; + if (dependsOnEndpoint(targetMoleculeType, firedChange.moleculeType2, + firedChange.componentIndex2)) + return true; + return false; +} + +bool EnergyRxnClass::shouldUpdateMembershipForChange( + Molecule *m, const IncrementalMembershipChange &change) const +{ + if (!simpleMembership || m == 0) + return true; + + MoleculeType *targetMoleculeType = m->getMoleculeType(); + if (targetMoleculeType == partnerMoleculeType && + change.moleculeType2 == partnerMoleculeType && + change.componentIndex2 == partnerComponentIndex) + return true; + + MoleculeType *weightedType = reactantTemplates[0]->getMoleculeType(); + if (targetMoleculeType != weightedType || + change.moleculeType1 != weightedType) + return false; + + if (change.componentIndex1 == reactionCenterComponentIndex) + return true; + if (change.componentIndex1 < 0 || change.componentIndex1 >= 64) + return true; + std::uint64_t changedBit = + (std::uint64_t(1) << change.componentIndex1); + if (dependencyMaskValid && + (weightedDependencyMask & changedBit) == 0) + return false; + if (!componentMaskFastPath) { + for (vector::const_iterator it = conditionComponentIndices.begin(); + it != conditionComponentIndices.end(); ++it) { + if (*it == change.componentIndex1) + return true; + } + return false; + } + + std::uint64_t newMask = m->getBoundComponentMask(); + bool observedBound = (newMask & changedBit) != 0; + if (observedBound != change.isBoundAfter1) + return true; + std::uint64_t oldMask = change.isBoundAfter1 + ? (newMask & ~changedBit) : (newMask | changedBit); + for (vector::const_iterator it = + conditionalComponentMasks.begin(); + it != conditionalComponentMasks.end(); ++it) { + std::uint64_t requiredMask = *it; + bool wasSatisfied = (oldMask & requiredMask) == requiredMask; + bool isSatisfied = (newMask & requiredMask) == requiredMask; + if (wasSatisfied != isSatisfied) + return true; + } + return false; +} + +bool EnergyRxnClass::canSkipIndirectMembership( + ReactionClass *firedReaction) const +{ + if (!simpleMembership || firedReaction == 0 || + !firedReaction->usesIncrementalMembership()) + return false; + const EnergyRxnClass *firedEnergy = + dynamic_cast(firedReaction); + return firedEnergy != 0 && firedEnergy->simpleMembership; +} + +bool EnergyRxnClass::canUseDirectProductList() const +{ + if (!simpleMembership || system == 0 || onTheFlyObservables || + transformationSet->getNumOfAddMoleculeTransforms() != 0) + return false; + + /* The direct list omits the rest of every affected complex. Require every + * molecule type to prove that indirect membership refresh is unnecessary, + * and reject any type-II dependency that would need the omitted molecules. */ + for (int i = 0; i < system->getNumOfMoleculeTypes(); ++i) { + MoleculeType *moleculeType = system->getMoleculeType(i); + if (moleculeType->getNumOfTypeIIFunctions() > 0 || + !moleculeType->canSkipIndirectMembership( + const_cast(this))) + return false; + } + return true; +} + +bool EnergyRxnClass::checkPreFireConditions( + MappingSet **mappingSets) const +{ + /* The compact forward constructor creates one binding transformation on + * reactant 0 and one empty partner mapping on reactant 1. If either site + * became occupied after membership was indexed, the transformation would + * reject the event later; reject it here before the generic fire pipeline. */ + if (!preFireBindingFastPath || + mappingSets == 0 || mappingSets[0] == 0 || mappingSets[1] == 0) + return true; + Mapping *weightedMapping = mappingSets[0]->get(0); + Mapping *partnerMapping = mappingSets[1]->get(0); + if (weightedMapping == 0 || partnerMapping == 0) + return true; + Molecule *weightedMolecule = weightedMapping->getMolecule(); + Molecule *partnerMolecule = partnerMapping->getMolecule(); + if (weightedMolecule == 0 || partnerMolecule == 0) + return true; + if (reactionCenterComponentIndex >= 0 && + reactionCenterComponentIndex < 64 && partnerComponentIndex >= 0 && + partnerComponentIndex < 64) { + std::uint64_t weightedBit = + (std::uint64_t(1) << reactionCenterComponentIndex); + std::uint64_t partnerBit = + (std::uint64_t(1) << partnerComponentIndex); + return (weightedMolecule->getBoundComponentMask() & weightedBit) == 0 && + (partnerMolecule->getBoundComponentMask() & partnerBit) == 0; + } + return !weightedMolecule->isBindingSiteBonded(weightedMapping->getIndex()) && + !partnerMolecule->isBindingSiteBonded(partnerMapping->getIndex()); +} + +bool EnergyRxnClass::refreshCompactPartnerPool( + Molecule *m, unsigned int reactantPos) +{ + if (!simpleMembership || !isForward || reactantPos != 1 || + m == 0 || partnerPool == 0) + return false; + return partnerPool->refresh(m, + static_cast(m->getMolListId()), + m->isBindingSiteOpen(partnerComponentIndex)); +} + +bool EnergyRxnClass::tryToAddAndReportChange( + Molecule *m, unsigned int reactantPos) +{ + if (!simpleMembership) { + tryToAdd(m, reactantPos); + return true; + } + return tryToAddCompact(m, reactantPos, -1); +} + +bool EnergyRxnClass::tryToAddWithIndex( + Molecule *m, unsigned int reactantPos, int rxnIndex) +{ + if (!simpleMembership) + return DORRxnClass::tryToAdd(m, reactantPos); + return tryToAddCompact(m, reactantPos, rxnIndex); +} + +bool EnergyRxnClass::tryToAddAndReportChangeWithIndex( + Molecule *m, unsigned int reactantPos, int rxnIndex) +{ + if (!simpleMembership) { + tryToAdd(m, reactantPos); + return true; + } + return tryToAddCompact(m, reactantPos, rxnIndex); +} + +bool EnergyRxnClass::tryToAdd(Molecule *m, unsigned int reactantPos) +{ + if (!simpleMembership) + return DORRxnClass::tryToAdd(m, reactantPos); + return tryToAddCompact(m, reactantPos, -1); +} + +bool EnergyRxnClass::tryToAddCompact( + Molecule *m, unsigned int reactantPos, int rxnIndex) +{ + /* The unweighted ligand side of a compact forward rule has exactly one + * empty-site constraint. All simple rules for the same endpoint share this + * pool, so only the first membership change needs to mutate storage. */ + if (isForward && reactantPos == 1) { + refreshCompactPartnerPool(m, reactantPos); + return true; + } + + if (reactantPos != static_cast(DORreactantIndex)) { + DORRxnClass::tryToAdd(m, reactantPos); + return true; + } + + if (rxnIndex < 0) + rxnIndex = m->getMoleculeType()->getRxnIndex(this, reactantPos); + Molecule *partnerMolecule = 0; + bool matches = false; + if (isForward) { + matches = m->isBindingSiteOpen(reactionCenterComponentIndex); + } else if (m->isBindingSiteBonded(reactionCenterComponentIndex)) { + partnerMolecule = m->getBondedMolecule(reactionCenterComponentIndex); + matches = partnerMolecule != 0 && + partnerMolecule->getMoleculeType() == partnerMoleculeType && + m->getBondedMoleculeBindingSiteIndex(reactionCenterComponentIndex) == + partnerComponentIndex; + } + if (!matches) { + bool changed = m->getRxnListMappingId(rxnIndex) >= 0; + while (m->getRxnListMappingId(rxnIndex) >= 0) { + int mappingId = m->getRxnListMappingId(rxnIndex); + m->deleteRxnListMappingId(rxnIndex, mappingId); + reactantTree->removeMappingSet(mappingId); + } + if (changed) + refreshCompactRateFactor(); + return changed; + } + + /* Keep the source algorithm's local-index behavior while reusing the + * molecule's compact membership storage. */ + const MappingIdSet& existingMappings = m->getRxnListMappingSet(rxnIndex); + if (!existingMappings.empty()) { + /* A simple compact energy rule has at most one mapping for its weighted + * molecule. Keep the common refresh on the existing tree node and avoid + * the iterator/setup work used by the general multi-mapping path. */ + if (existingMappings.size() == 1) { + int mappingId = *existingMappings.begin(); + MappingSet *mappingSet = reactantTree->getMappingSet(mappingId); + if (mappingSet != 0 && mappingSet->get(0) != 0 && + mappingSet->get(0)->getMolecule() == m) { + bool mappingChanged = false; + if (!isForward) { + mappingChanged = mappingSet->get(1)->getMolecule() != + partnerMolecule; + if (mappingChanged) + mappingSet->set(1, partnerMolecule); + } + bool rateChanged = reactantTree->updateValue( + mappingId, evaluateLocalFunctions(mappingSet)); + refreshCompactRateFactor(); + return mappingChanged || rateChanged; + } + } + bool changed = false; + for (MappingIdSet::const_iterator it = existingMappings.begin(); + it != existingMappings.end(); ++it) { + MappingSet *mappingSet = reactantTree->getMappingSet(*it); + if (mappingSet->get(0)->getMolecule() != m) + changed = true; + mappingSet->set(0, m); + if (!isForward) { + if (mappingSet->get(1)->getMolecule() != partnerMolecule) + changed = true; + mappingSet->set(1, partnerMolecule); + } + if (reactantTree->updateValue( + *it, evaluateLocalFunctions(mappingSet))) + changed = true; + } + refreshCompactRateFactor(); + return changed; + } + + MappingSet *mappingSet = reactantTree->pushNextAvailableMappingSet(); + mappingSet->set(0, m); + if (!isForward) + mappingSet->set(1, partnerMolecule); + reactantTree->confirmPush( + mappingSet->getId(), evaluateLocalFunctions(mappingSet)); + m->setRxnListMappingId(rxnIndex, mappingSet->getId()); + refreshCompactRateFactor(); + return true; +} + +void EnergyRxnClass::notifyRateFactorChange( + Molecule *m, int reactantIndex, int rxnListIndex) +{ + DORRxnClass::notifyRateFactorChange(m, reactantIndex, rxnListIndex); + refreshCompactRateFactor(); +} + +void EnergyRxnClass::remove(Molecule *m, unsigned int reactantPos) +{ + if (simpleMembership && isForward && reactantPos == 1) { + if (partnerPool != 0) + partnerPool->remove(m, + static_cast(m->getMolListId())); + return; + } + DORRxnClass::remove(m, reactantPos); + if (compactFactorizedPropensity) + refreshCompactRateFactor(); +} + +double EnergyRxnClass::update_a() +{ + if (compactFactorizedPropensity && !useRuleMonkey) { + if (compactForwardPartnerPropensity) { + a = baseRate * compactRateFactor * + static_cast(partnerPool->size()); + return a; + } + if (compactReversePropensity) { + a = baseRate * compactRateFactor; + return a; + } + } + return DORRxnClass::update_a(); +} + +double EnergyRxnClass::get_a() const +{ + if (compactForwardPartnerPropensity && !useRuleMonkey && partnerPool != 0) + return baseRate * compactRateFactor * + static_cast(partnerPool->size()); + return ReactionClass::get_a(); +} + +double EnergyRxnClass::getCompactPartnerPoolCoefficient() const +{ + if (compactForwardPartnerPropensity && !useRuleMonkey) + return baseRate * compactRateFactor; + return 0.0; +} + +double EnergyRxnClass::update_a_for_compact_partner_pool(int poolSize) +{ + if (compactForwardPartnerPropensity && !useRuleMonkey && partnerPool != 0) { + a = getCompactPartnerPoolCoefficient() * + static_cast(poolSize); + return a; + } + return update_a(); +} + +int EnergyRxnClass::getReactantCount(unsigned int reactantIndex) const +{ + if (simpleMembership && isForward && reactantIndex == 1) + return partnerPool == 0 ? 0 : partnerPool->size(); + return DORRxnClass::getReactantCount(reactantIndex); +} + +int EnergyRxnClass::getCorrectedReactantCount(unsigned int reactantIndex) const +{ + if (simpleMembership && isForward && reactantIndex == 1) + return partnerPool == 0 ? 0 : partnerPool->size(); + return DORRxnClass::getCorrectedReactantCount(reactantIndex); +} + +void EnergyRxnClass::pickMappingSets(double random_A_number) const +{ + if (!(simpleMembership && isForward && n_reactants == 2 && + DORreactantIndex == 0 && partnerPool != 0)) { + DORRxnClass::pickMappingSets(random_A_number); + return; + } + + int partnerCount = partnerPool->size(); + if (partnerCount == 0) return; + int partnerIndex = system->getMappingRNG().random_int(0, + static_cast(partnerCount)); + compactPartnerMappingSet->set(0, + partnerPool->getByIndex(static_cast(partnerIndex))); + mappingSet[1] = compactPartnerMappingSet; + + double rateFactorMultiplier = baseRate * static_cast(partnerCount); + if (random_A_number < 0) + random_A_number = system->getRNG().random(this->a); + reactantTree->pickReactantFromValue( + mappingSet[DORreactantIndex], random_A_number, + rateFactorMultiplier); +} + +double EnergyRxnClass::evaluateLocalFunctions(MappingSet *ms) +{ + if (ms == 0 || ms->getNumOfMappings() == 0 || ms->get(0) == 0 || + ms->get(0)->getMolecule() == 0) { + return 0.0; + } + + Molecule *weightedMolecule = ms->get(0)->getMolecule(); + if (singleConditionalTermFastPath) { + std::uint64_t boundMask = weightedMolecule->getBoundComponentMask(); + return (boundMask & conditionalComponentMasks[0]) == + conditionalComponentMasks[0] + ? conditionedEnergyRateFactor : baseEnergyRateFactor; + } + if (multiConditionalTermFastPath) { + std::uint64_t boundMask = weightedMolecule->getBoundComponentMask(); + unsigned int activeTerms = 0; + for (unsigned int ti = 0; ti < conditionalTerms.size(); ++ti) { + std::uint64_t requiredMask = conditionalComponentMasks[ti]; + if ((boundMask & requiredMask) == requiredMask) + activeTerms |= (1u << ti); + } + return conditionalRateFactors[activeTerms]; + } + std::uint64_t conditionMask = 0; + for (unsigned int ci=0; ciisBindingSiteBonded(conditionComponentIndices[ci])) + conditionMask |= (std::uint64_t(1) << ci); + } + + double deltaG = baseEnergy; + for (const auto &term : conditionalTerms) { + if ((conditionMask & term.conditionMask) == term.conditionMask) + deltaG += term.energyValue; + } + + /* DOR's base rate carries exp(-Ea0/RT); this factor carries only the + * context-dependent Arrhenius contribution. */ + double energyCoefficient = isForward ? phi : (phi - 1.0); + return std::exp(-(energyCoefficient * deltaG) / RT); +} + +double EnergyRxnClass::exactRuleMonkey_a() +{ + /* Keep the same total-rate convention as DORRxnClass. The compact path is + * otherwise a two-reactant microscopic rule with the first reactant + * weighted by its context-dependent energy factor. */ + if (totalRateFlag) { + double exact_a = baseRate; + for (unsigned int i=0; isize(); + if (compactPartnerMappingSet == 0) return 0.0; + + double validPropensity = 0.0; + for (int i=0; isize(); ++i) { + msPairBuffer[0] = reactantTree->getMappingSetByIndex(i); + for (int j=0; jset(0, + partnerPool->getByIndex(static_cast(j))); + msPairBuffer[1] = compactPartnerMappingSet; + if (transformationSet->checkMolecularity(msPairBuffer)) + validPropensity += baseRate * + reactantTree->getRateFactor(i); + } + } + return validPropensity; + } + + if (n_reactants != 2 || DORreactantIndex != 0) + return DORRxnClass::exactRuleMonkey_a(); + + /* The inherited DOR implementation expects a ReactantList at index 0, + * but the compact reaction stores the weighted first reactant in its + * ReactantTree. Enumerate the same valid pairs directly. */ + ReactantList *partnerList = reactantLists[1]; + if (partnerList == 0) return 0.0; + + double validPropensity = 0.0; + for (int i=0; isize(); ++i) { + msPairBuffer[0] = reactantTree->getMappingSetByIndex(i); + for (int j=0; jsize(); ++j) { + msPairBuffer[1] = partnerList->getMappingSetByIndex(j); + if (transformationSet->checkMolecularity(msPairBuffer)) + validPropensity += baseRate * reactantTree->getRateFactor(i); + } + } + return validPropensity; +} + +void EnergyRxnClass::pickRuleMonkeyMappingSets(double random_A_number) const +{ + if (simpleMembership && isForward && n_reactants == 2 && + DORreactantIndex == 0) { + int partnerCount = partnerPool == 0 ? 0 : partnerPool->size(); + if (partnerCount == 0 || reactantTree->size() == 0 || + compactPartnerMappingSet == 0) + return; + + validPairsBuffer.clear(); + validWeightsBuffer.clear(); + double totalWeight = 0.0; + for (int i=0; isize(); ++i) { + msPairBuffer[0] = reactantTree->getMappingSetByIndex(i); + for (int j=0; jset(0, + partnerPool->getByIndex(static_cast(j))); + msPairBuffer[1] = compactPartnerMappingSet; + if (!transformationSet->checkMolecularity(msPairBuffer)) continue; + + validPairsBuffer.push_back(make_pair(i,j)); + double weight = reactantTree->getRateFactor(i); + validWeightsBuffer.push_back(weight); + totalWeight += weight; + } + } + + if (validPairsBuffer.empty() || totalWeight <= 0.0) return; + + double randNum = system->getRNG().random(totalWeight); + double cumulative = 0.0; + size_t selectedIndex = validPairsBuffer.size() - 1; + for (size_t k=0; k selected = validPairsBuffer[selectedIndex]; + mappingSet[0] = reactantTree->getMappingSetByIndex(selected.first); + compactPartnerMappingSet->set(0, + partnerPool->getByIndex(static_cast(selected.second))); + mappingSet[1] = compactPartnerMappingSet; + return; + } + + if (n_reactants != 2 || DORreactantIndex != 0) { + DORRxnClass::pickRuleMonkeyMappingSets(random_A_number); + return; + } + + ReactantList *partnerList = reactantLists[1]; + if (partnerList == 0 || reactantTree->size() == 0 || partnerList->size() == 0) + return; + + /* RuleMonkey promises to remove null molecularity events exactly. */ + validPairsBuffer.clear(); + validWeightsBuffer.clear(); + double totalWeight = 0.0; + for (int i=0; isize(); ++i) { + msPairBuffer[0] = reactantTree->getMappingSetByIndex(i); + for (int j=0; jsize(); ++j) { + msPairBuffer[1] = partnerList->getMappingSetByIndex(j); + if (!transformationSet->checkMolecularity(msPairBuffer)) continue; + + validPairsBuffer.push_back(make_pair(i,j)); + double weight = reactantTree->getRateFactor(i); + validWeightsBuffer.push_back(weight); + totalWeight += weight; + } + } + + if (validPairsBuffer.empty() || totalWeight <= 0.0) return; + + double randNum = system->getRNG().random(totalWeight); + double cumulative = 0.0; + size_t selectedIndex = validPairsBuffer.size() - 1; + for (size_t k=0; k selected = validPairsBuffer[selectedIndex]; + mappingSet[0] = reactantTree->getMappingSetByIndex(selected.first); + mappingSet[1] = partnerList->getMappingSetByIndex(selected.second); +} + + /* * DOR2RxnClass */ @@ -1184,6 +2140,14 @@ bool DOR2RxnClass::tryToAdd(Molecule *m, unsigned int reactantPos) { // adding molecule to DOR2RxnClass //if(DEBUG_MESSAGE)m->printDetails(); + if (contextCountsPerComplex[reactantPos] && m->getComplex() != 0) { + if (reactantPos == (unsigned)DORreactantIndex1) + reactantTree1->noteMappedComplexSize(m->getComplex()->getComplexSize()); + else if (reactantPos == (unsigned)DORreactantIndex2) + reactantTree2->noteMappedComplexSize(m->getComplex()->getComplexSize()); + else + reactantLists[reactantPos]->noteMappedComplexSize(m->getComplex()->getComplexSize()); + } if (reactantPos==(unsigned)this->DORreactantIndex1) { // handle the DOR reactant @@ -1311,26 +2275,18 @@ int DOR2RxnClass::getReactantCount(unsigned int reactantIndex) const int DOR2RxnClass::getCorrectedReactantCount(unsigned int reactantIndex) const { if (reactantIndex==(unsigned)DORreactantIndex1) { - return reactantTree1->size(); + return contextCountsPerComplex[reactantIndex] + ? countDistinctComplexes(reactantTree1) : reactantTree1->size(); } else if (reactantIndex==(unsigned)DORreactantIndex2) { - return reactantTree2->size(); + return contextCountsPerComplex[reactantIndex] + ? countDistinctComplexes(reactantTree2) : reactantTree2->size(); } - if (matchOncePerReactant[reactantIndex] && !isPopulationType[reactantIndex]) { - std::set uniqueComplexes; - ReactantList *rl = reactantLists[reactantIndex]; - int size = rl->size(); - for (int i = 0; i < size; ++i) { - MappingSet *ms = rl->getMappingSetByIndex(i); - if (ms && ms->getNumOfMappings() > 0) { - Mapping *mapping = ms->get(0); - if (mapping && mapping->getMolecule()) { - uniqueComplexes.insert(mapping->getMolecule()->getComplexID()); - } - } - } - return (int)uniqueComplexes.size(); + if ((matchOncePerReactant[reactantIndex] || + contextCountsPerComplex[reactantIndex]) && + !isPopulationType[reactantIndex]) { + return countDistinctComplexes(reactantLists[reactantIndex]); } return isPopulationType[reactantIndex] ? @@ -1426,11 +2382,15 @@ double DOR2RxnClass::update_a() { //cout << "baseRate=" << baseRate << endl; for (unsigned int i=0; igetRateFactorSum(); + a *= contextCountsPerComplex[i] + ? perComplexRateFactorSum(reactantTree1) + : reactantTree1->getRateFactorSum(); //cout << i << ":rateFactorSum1=" << reactantTree1->getRateFactorSum() << endl; } else if (i==(unsigned int)DORreactantIndex2) { - a*=reactantTree2->getRateFactorSum(); + a *= contextCountsPerComplex[i] + ? perComplexRateFactorSum(reactantTree2) + : reactantTree2->getRateFactorSum(); //cout << i << ":rateFactorSum2=" << reactantTree2->getRateFactorSum() << endl; } else { @@ -1486,38 +2446,48 @@ double DOR2RxnClass::exactRuleMonkey_a() validCombinations = 1.0; } else if (n_reactants == 1) { if (0 == DORreactantIndex1) { - validCombinations = reactantTree1->getRateFactorSum(); + validCombinations = contextCountsPerComplex[0] + ? perComplexRateFactorSum(reactantTree1) + : reactantTree1->getRateFactorSum(); } else if (0 == DORreactantIndex2) { - validCombinations = reactantTree2->getRateFactorSum(); + validCombinations = contextCountsPerComplex[0] + ? perComplexRateFactorSum(reactantTree2) + : reactantTree2->getRateFactorSum(); } else { validCombinations = getCorrectedReactantCount(0); } } else if (n_reactants == 2) { - int size0 = getReactantCount(0); - int size1 = getReactantCount(1); + static thread_local vector reps0, reps1; + static thread_local vector idx0, idx1; + collectReactantRepresentatives(reactantLists[0], contextCountsPerComplex[0], reps0, &idx0); + collectReactantRepresentatives(reactantLists[1], contextCountsPerComplex[1], reps1, &idx1); double totalCombinations = 1.0; for (unsigned int i=0; igetRateFactorSum(); + totalCombinations *= contextCountsPerComplex[i] + ? perComplexRateFactorSum(reactantTree1) + : reactantTree1->getRateFactorSum(); } else if (i==(unsigned int)DORreactantIndex2) { - totalCombinations*=reactantTree2->getRateFactorSum(); + totalCombinations *= contextCountsPerComplex[i] + ? perComplexRateFactorSum(reactantTree2) + : reactantTree2->getRateFactorSum(); } else { - totalCombinations*=(double)getReactantCount(i); + totalCombinations*=(double)getCorrectedReactantCount(i); } } double invalidCombinations = 0; - for (int i = 0; i < size0; ++i) { - msPairBuffer[0] = reactantLists[0]->getMappingSet(i); - for (int j = 0; j < size1; ++j) { - msPairBuffer[1] = reactantLists[1]->getMappingSet(j); + for (size_t i = 0; i < reps0.size(); ++i) { + msPairBuffer[0] = reps0[i]; + for (size_t j = 0; j < reps1.size(); ++j) { + msPairBuffer[1] = reps1[j]; if (!transformationSet->checkMolecularity(msPairBuffer)) { - double weight0 = (0 == DORreactantIndex1) ? reactantTree1->getRateFactor(i) : - ((0 == DORreactantIndex2) ? reactantTree2->getRateFactor(i) : 1.0); - double weight1 = (1 == DORreactantIndex1) ? reactantTree1->getRateFactor(j) : - ((1 == DORreactantIndex2) ? reactantTree2->getRateFactor(j) : 1.0); + double weight0 = (0 == DORreactantIndex1) ? reactantTree1->getRateFactor(idx0[i]) : + ((0 == DORreactantIndex2) ? reactantTree2->getRateFactor(idx0[i]) : 1.0); + double weight1 = (1 == DORreactantIndex1) ? reactantTree1->getRateFactor(idx1[j]) : + ((1 == DORreactantIndex2) ? reactantTree2->getRateFactor(idx1[j]) : 1.0); invalidCombinations += (weight0 * weight1); } } @@ -1528,9 +2498,13 @@ double DOR2RxnClass::exactRuleMonkey_a() validCombinations = 1.0; for (unsigned int i=0; igetRateFactorSum(); + validCombinations *= contextCountsPerComplex[i] + ? perComplexRateFactorSum(reactantTree1) + : reactantTree1->getRateFactorSum(); } else if (i==(unsigned int)DORreactantIndex2) { - validCombinations*=reactantTree2->getRateFactorSum(); + validCombinations *= contextCountsPerComplex[i] + ? perComplexRateFactorSum(reactantTree2) + : reactantTree2->getRateFactorSum(); } else { validCombinations*=(double)getCorrectedReactantCount(i); } @@ -1547,9 +2521,9 @@ void DOR2RxnClass::pickRuleMonkeyMappingSets(double random_A_number) const for(unsigned int i=0; ipickRandomFromPopulation(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandomFromPopulation(mappingSet[i], system->getMappingRNG()); } else { - reactantLists[i]->pickRandom(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandom(mappingSet[i], system->getMappingRNG()); } } } @@ -1594,9 +2568,9 @@ void DOR2RxnClass::pickRuleMonkeyMappingSets(double random_A_number) const for(unsigned int i=0; ipickRandomFromPopulation(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandomFromPopulation(mappingSet[i], system->getMappingRNG()); } else { - reactantLists[i]->pickRandom(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandom(mappingSet[i], system->getMappingRNG()); } } } @@ -1639,9 +2613,9 @@ void DOR2RxnClass::pickMappingSets(double randNumber) const for(unsigned int i=0; ipickRandomFromPopulation(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandomFromPopulation(mappingSet[i], system->getMappingRNG()); } else { - reactantLists[i]->pickRandom(mappingSet[i], system->getRNG()); + reactantLists[i]->pickRandom(mappingSet[i], system->getMappingRNG()); } //rateFactorMultiplier*=getReactantCount(i); } @@ -1699,6 +2673,3 @@ void DOR2RxnClass::printDetails() const if (n_reactants==0) cout << " >No Reactants: so this rule either creates new species or does nothing."< complexScratch; + + template + int distinctComplexesOf(Container *container, int size) + { + complexScratch.clear(); + if (static_cast(complexScratch.capacity()) < size) + complexScratch.reserve(size); + for (int i = 0; i < size; ++i) { + MappingSet *ms = container->getMappingSetByIndex(i); + if (!ms || ms->getNumOfMappings() == 0) continue; + Mapping *mapping = ms->get(0); + if (mapping && mapping->getMolecule()) + complexScratch.push_back(mapping->getMolecule()->getComplexID()); + } + sort(complexScratch.begin(), complexScratch.end()); + return static_cast(unique(complexScratch.begin(), complexScratch.end()) - + complexScratch.begin()); + } +} + +int NFcore::countDistinctComplexes(ReactantList *rl) +{ + if (!rl->mayShareComplexes()) return rl->size(); + return distinctComplexesOf(rl, rl->size()); +} + +int NFcore::countDistinctComplexes(ReactantTree *tree) +{ + if (!tree->mayShareComplexes()) return tree->size(); + return distinctComplexesOf(tree, tree->size()); +} + +double NFcore::perComplexRateFactorSum(ReactantTree *tree) +{ + set seen; + double sum = 0.0; + for (int i = 0; i < tree->size(); ++i) { + MappingSet *ms = tree->getMappingSetByIndex(i); + if (!ms || ms->getNumOfMappings() == 0) continue; + Mapping *mapping = ms->get(0); + if (!mapping || !mapping->getMolecule()) continue; + if (seen.insert(mapping->getMolecule()->getComplexID()).second) + sum += tree->getRateFactor(i); + } + return sum; +} + +void NFcore::collectReactantRepresentatives(ReactantList *rl, bool perComplex, + vector &out, vector *flatIndices) +{ + out.clear(); + if (flatIndices) flatIndices->clear(); + if (!perComplex || !rl->mayShareComplexes()) { + for (int i = 0; i < rl->size(); ++i) { + MappingSet *ms = rl->getMappingSetByIndex(i); + if (ms) { + out.push_back(ms); + if (flatIndices) flatIndices->push_back(i); + } + } + return; + } + + set seen; + for (int i = 0; i < rl->size(); ++i) { + MappingSet *ms = rl->getMappingSetByIndex(i); + if (!ms || ms->getNumOfMappings() == 0) continue; + Mapping *mapping = ms->get(0); + if (!mapping || !mapping->getMolecule()) continue; + if (seen.insert(mapping->getMolecule()->getComplexID()).second) { + out.push_back(ms); + if (flatIndices) flatIndices->push_back(i); + } + } +} + @@ -87,6 +165,16 @@ double FunctionalRxnClass::update_a() { a *= this->volumeConversionFactor; + // FunctionalRxnClass is constructed with baseRate=1, so the constructor's + // symmetry correction is otherwise lost for ordinary functional rates. + // TotalRate already states the complete propensity and must not receive that + // reaction-center correction. This placement matches NFsim commit 1b19611: + // the loader sets totalRateFlag after construction and setBaseRate(). + if (!this->totalRateFlag) + { + a *= this->baseRate; + } + if(a<0) { cout<<"Warning!! The function you provided for functional rxn: '"<= 0.0 && S >= 0.0) + ? std::hypot(b, 2.0 * std::sqrt(KmS)) + : std::sqrt(b * b + 4.0 * KmS); + if (b < 0.0 && KmS > 0.0) { + sFree = (2.0 * KmS) / (discriminant - b); + } else { + sFree = 0.5 * (b + discriminant); + } + const double denominator = Km + sFree; + a = (denominator > 0.0 && std::isfinite(denominator)) + ? kcat * sFree * E / denominator + : 0.0; return a; } @@ -403,8 +508,8 @@ int BasicRxnClass::checkForEquality(Molecule *m, MappingSet* ms, int rxnIndex, R /* Check if mapping set clashes with any of the mapping sets already in reactantList */ - set tempSet = m->getRxnListMappingSet(rxnIndex); - for(set::iterator it= tempSet.begin();it!= tempSet.end(); ++it){ + const MappingIdSet& tempSet = m->getRxnListMappingSet(rxnIndex); + for(MappingIdSet::const_iterator it= tempSet.begin();it!= tempSet.end(); ++it){ MappingSet* ms2 = reactantList->getMappingSet(*it); if(MappingSet::checkForEquality(ms,ms2)){ return *it; @@ -524,7 +629,10 @@ bool BasicRxnClass::tryToAdd(Molecule *m, unsigned int reactantPos) // } //Here we get the standard update... - set deleteMs = m->getRxnListMappingSet(rxnIndex); + MappingIdSet deleteMs = m->getRxnListMappingSet(rxnIndex); + if (contextCountsPerComplex[reactantPos] && m->getComplex() != 0) { + rl->noteMappedComplexSize(m->getComplex()->getComplexSize()); + } //Try to map it! MappingSet *ms = rl->pushNextAvailableMappingSet(); @@ -569,7 +677,7 @@ bool BasicRxnClass::tryToAdd(Molecule *m, unsigned int reactantPos) } - for (set::iterator it = deleteMs.begin(); it != deleteMs.end(); ++it) { + for (MappingIdSet::iterator it = deleteMs.begin(); it != deleteMs.end(); ++it) { rl->removeMappingSet(*it); m->deleteRxnListMappingId(rxnIndex, *it); } @@ -631,17 +739,17 @@ double BasicRxnClass::exactRuleMonkey_a() } else if (n_reactants == 1) { validCombinations = getCorrectedReactantCount(0); } else if (n_reactants == 2) { - // Exact calculation: subtract null events - int size0 = getReactantCount(0); - int size1 = getReactantCount(1); - // Use raw counts here because invalid self-pairs are removed explicitly below. - double totalCombinations = (double)getReactantCount(0) * (double)getReactantCount(1); + // Enumerate one representative per complex for pure context reactants. + static thread_local vector reps0, reps1; + collectReactantRepresentatives(reactantLists[0], contextCountsPerComplex[0], reps0); + collectReactantRepresentatives(reactantLists[1], contextCountsPerComplex[1], reps1); + double totalCombinations = (double)reps0.size() * (double)reps1.size(); double invalidCombinations = 0; - for (int i = 0; i < size0; ++i) { - msPairBuffer[0] = reactantLists[0]->getMappingSet(i); - for (int j = 0; j < size1; ++j) { - msPairBuffer[1] = reactantLists[1]->getMappingSet(j); + for (size_t i = 0; i < reps0.size(); ++i) { + msPairBuffer[0] = reps0[i]; + for (size_t j = 0; j < reps1.size(); ++j) { + msPairBuffer[1] = reps1[j]; // check for collision if (!transformationSet->checkMolecularity(msPairBuffer)) { @@ -710,20 +818,10 @@ int BasicRxnClass::getCorrectedReactantCount(unsigned int reactantIndex) const } */ - if (matchOncePerReactant[reactantIndex] && !isPopulationType[reactantIndex]) { - std::set uniqueComplexes; - ReactantList *rl = reactantLists[reactantIndex]; - int size = rl->size(); - for (int i = 0; i < size; ++i) { - MappingSet *ms = rl->getMappingSetByIndex(i); - if (ms && ms->getNumOfMappings() > 0) { - Mapping *mapping = ms->get(0); - if (mapping && mapping->getMolecule()) { - uniqueComplexes.insert(mapping->getMolecule()->getComplexID()); - } - } - } - return (int)uniqueComplexes.size(); + if ((matchOncePerReactant[reactantIndex] || + contextCountsPerComplex[reactantIndex]) && + !isPopulationType[reactantIndex]) { + return countDistinctComplexes(reactantLists[reactantIndex]); } return isPopulationType[reactantIndex] ? @@ -742,7 +840,7 @@ void BasicRxnClass::printFullDetails() const void BasicRxnClass::pickRuleMonkeyMappingSets(double random_A_number) const { - NfsimRNG& rng = system->getRNG(); + NfsimRNG& rng = system->getMappingRNG(); if (n_reactants != 2 || totalRateFlag) { for(unsigned int i=0; igetRNG(); + NfsimRNG& rng = system->getMappingRNG(); for(unsigned int i=0; i &out, std::vector *flatIndices = 0); class BasicRxnClass : public ReactionClass { public: @@ -129,6 +134,25 @@ namespace NFcore CompositeFunction *function, vector &lfArgumentPointerNameList, System *s); + /* Internal constructor for reactions whose rate factor is supplied by + * another mapping-local evaluator rather than a BNGL local function. */ + DORRxnClass( + string name, + double baseRate, + string baseRateName, + TransformationSet *transformationSet, + int dorReactantIndex, + System *s); + DORRxnClass( + string name, + double baseRate, + string baseRateName, + TransformationSet *transformationSet, + int dorReactantIndex, + System *s, + unsigned int reactantListInitialCapacity, + unsigned int reactantTreeInitialCapacity, + bool allocateReactantLists); virtual ~DORRxnClass(); virtual void init(); @@ -199,6 +223,136 @@ namespace NFcore }; + /* + * Compact Arrhenius energy reaction. This uses the DOR mapping tree to + * select a reaction-center molecule with its context-dependent rate factor, + * without materializing one reaction class per boolean context state. + */ + class EnergyRxnClass : public DORRxnClass { + public: + EnergyRxnClass( + string name, + double baseRate, + string baseRateName, + TransformationSet *transformationSet, + int dorReactantIndex, + const EnergyBindingContext &context, + double phi, + double RT, + bool isForward, + System *s); + virtual ~EnergyRxnClass(); + virtual bool usesIncrementalMembership() const { + return simpleMembership; + } + virtual bool membershipDecisionIsTypeInvariant() const { + return simpleMembership; + } + virtual bool supportsSparseSelection() const { + return simpleMembership; + } + virtual bool canUseDirectProductList() const; + virtual bool getIncrementalMembershipChange( + IncrementalMembershipChange &change) const; + virtual bool getCompactMembershipIndexInfo( + unsigned int reactantPos, + int &reactionCenterComponent, + std::uint64_t &contextComponentMask, + unsigned int &minimumContextComponents) const; + virtual bool shouldUpdateMembership( + Molecule *m, ReactionClass *firedReaction, + bool directProduct) const; + virtual bool shouldUpdateMembershipForChange( + Molecule *m, + const IncrementalMembershipChange &change) const; + virtual bool canSkipIndirectMembership( + ReactionClass *firedReaction) const; + virtual bool checkPreFireConditions( + MappingSet **mappingSets) const; + virtual CompactPartnerPool *getCompactPartnerPool() const { + return partnerPool; + } + virtual bool getCompactPartnerPoolInfo( + unsigned int reactantPos, int &partnerComponent) const { + if (!simpleMembership || !isForward || reactantPos != 1 || + partnerComponentIndex < 0) + return false; + partnerComponent = partnerComponentIndex; + return true; + } + virtual bool refreshCompactPartnerPool( + Molecule *m, unsigned int reactantPos); + virtual bool supportsCompactPartnerPoolUpdate() const { + return simpleMembership && isForward; + } + virtual bool supportsCompactPartnerPoolScale() const { + return simpleMembership && isForward && !useRuleMonkey; + } + virtual bool supportsDeferredMembershipUpdate() const { + return simpleMembership; + } + virtual bool tryToAddAndReportChange( + Molecule *m, unsigned int reactantPos); + virtual bool tryToAddWithIndex( + Molecule *m, unsigned int reactantPos, int rxnIndex); + virtual bool tryToAddAndReportChangeWithIndex( + Molecule *m, unsigned int reactantPos, int rxnIndex); + virtual bool tryToAdd(Molecule *m, unsigned int reactantPos); + virtual void remove(Molecule *m, unsigned int reactantPos); + virtual double update_a(); + virtual double get_a() const; + virtual double getCompactPartnerPoolCoefficient() const; + virtual double update_a_for_compact_partner_pool(int poolSize); + virtual void notifyRateFactorChange( + Molecule *m, int reactantIndex, int rxnListIndex); + virtual int getReactantCount(unsigned int reactantIndex) const; + virtual int getCorrectedReactantCount(unsigned int reactantIndex) const; + + protected: + virtual void pickMappingSets(double randNumber) const; + virtual double evaluateLocalFunctions(MappingSet *ms); + virtual void pickRuleMonkeyMappingSets(double randNumber) const; + virtual double exactRuleMonkey_a(); + bool tryToAddCompact( + Molecule *m, unsigned int reactantPos, int rxnIndex = -1); + + private: + vector conditionalTerms; + vector conditionComponentIndices; + vector conditionalComponentMasks; + bool componentMaskFastPath; + bool simpleMembership; + bool compactFactorizedPropensity; + bool compactForwardPartnerPropensity; + bool compactReversePropensity; + bool preFireBindingFastPath; + int reactionCenterComponentIndex; + int partnerComponentIndex; + MoleculeType *partnerMoleculeType; + CompactPartnerPool *partnerPool; + MappingSet *compactPartnerMappingSet; + double compactRateFactor; + double baseEnergy; + double phi; + double RT; + bool isForward; + std::uint64_t weightedDependencyMask; + bool dependencyMaskValid; + bool singleConditionalTermFastPath; + double baseEnergyRateFactor; + double conditionedEnergyRateFactor; + bool multiConditionalTermFastPath; + vector conditionalRateFactors; + unsigned int minimumConditionalBits; + + void refreshCompactRateFactor(); + + bool dependsOnEndpoint( + MoleculeType *targetMoleculeType, + MoleculeType *changedMoleculeType, + int changedComponentIndex) const; + }; + /* A reaction class with DOR calculations on two reactants. * The rate function must be factored as f(x,y) = g(x)*h(y) * */ diff --git a/cpp/nfsim/NFreactions/transformations/transformationSet.cpp b/cpp/nfsim/NFreactions/transformations/transformationSet.cpp index 336441cd..fa8221a3 100644 --- a/cpp/nfsim/NFreactions/transformations/transformationSet.cpp +++ b/cpp/nfsim/NFreactions/transformations/transformationSet.cpp @@ -473,47 +473,64 @@ bool TransformationSet::addAddMolecule( MoleculeCreator *mc ) -bool TransformationSet::canReachExcludingBond(Molecule *mol1, Molecule *mol2, int excludeComponentIndex) +bool TransformationSet::canReachExcludingBonds( + Molecule *mol1, Molecule *mol2, + const vector< pair > &excludedBonds) { - // Perform a BFS from mol2 to see if mol1 is reachable while excluding - // the specific bond at excludeComponentIndex on mol2. - // Returns true if mol1 can be reached, false otherwise. - - int excludeComponentIndexMol2 = mol1->getBondedMoleculeBindingSiteIndex(excludeComponentIndex); - - queue q; - list visited; - + // Perform a BFS from mol2 to see if mol1 is reachable while refusing to + // traverse any of the bonds in excludedBonds. Each excluded bond is recorded + // as both of its (molecule, component-index) half-edges, so the bond is + // skipped regardless of which endpoint the BFS reaches first. + // Returns true if mol1 can still be reached, false otherwise. + // + // Excluding the full set of bonds that the firing rule deletes (rather than + // one bond at a time) is what lets a multi-bond ring-opening dissociation be + // recognized as genuinely separating its products: each individual bond may + // leave the partners connected, while removing all of them does not. + + // Use static traversal storage to reuse the BFS infrastructure. + static queue q; + static list visited; + + while (!q.empty()) q.pop(); + visited.clear(); // Start BFS from mol2 q.push(mol2); visited.push_back(mol2); mol2->setVisitedMolecule(true); bool found = false; - + while(!q.empty()) { Molecule *current = q.front(); q.pop(); - + // Check if we reached mol1 if (current == mol1) { found = true; break; } - + // Explore neighbors through bonds int numComponents = current->getMoleculeType()->getNumOfComponents(); for (int c = 0; c < numComponents; ++c) { - // Skip the excluded bond from both endpoints. - if ((current == mol1 && c == excludeComponentIndex) || - (current == mol2 && c == excludeComponentIndexMol2)) { + // Skip any half-edge belonging to a bond the rule will delete. + bool isExcluded = false; + for (unsigned int e = 0; e < excludedBonds.size(); ++e) { + if (excludedBonds[e].first == current && + excludedBonds[e].second == c) { + isExcluded = true; + break; + } + } + if (isExcluded) { continue; } - + // Check if this component is bonded if (current->isBindingSiteBonded(c)) { Molecule *neighbor = current->getBondedMolecule(c); - + // Check if we've already visited this neighbor if (!neighbor->getVisitedMolecule()) { // Haven't visited this neighbor yet @@ -524,7 +541,7 @@ bool TransformationSet::canReachExcludingBond(Molecule *mol1, Molecule *mol2, in } } } - + // Clear the hasVisitedMolecule values before returning for (list::iterator it = visited.begin(); it != visited.end(); ++it) { (*it)->setVisitedMolecule(false); @@ -686,54 +703,62 @@ bool TransformationSet::checkMolecularity( MappingSet ** mappingSets ) // Issue #48: NFsim does not enforce product molecularity for unimolecular unbinding rules if ( n_reactants == 1 && complex_bookkeeping && n_product_patterns > 1 ) { - // Check each transformation for unbinding that would violate product molecularity + // First pass: gather every bond this rule's unbinding transforms will + // delete. The connectivity test below must reflect the post-reaction + // graph with all of them removed at once. Testing each bond in isolation + // wrongly blocks multi-bond ring-opening dissociations. + vector< pair > excludedBonds; + vector< pair > brokenPairs; for ( unsigned int t = 0; t < getNumOfTransformations(0); ++t ) { Transformation * transform = getTransformation(0, t); - if ( transform->getType() == TransformationFactory::UNBINDING ) + if ( transform->getType() != TransformationFactory::UNBINDING ) + continue; + + UnbindingTransform * unbindingTransform = + static_cast( transform ); + + Mapping * mapping = mappingSets[0]->get(t); + if (mapping == NULL) { + continue; + } + + // Get the molecule that will be unbound + Molecule * mol = mapping->getMolecule(); + if (mol == NULL) { + continue; + } + int componentIndex = unbindingTransform->getComponentIndex(); + + // Get the molecule bonded at this site + if ( !mol->isBindingSiteBonded(componentIndex) ) { - UnbindingTransform * unbindingTransform = - static_cast( transform ); - - Mapping * mapping = mappingSets[0]->get(t); - if (mapping == NULL) { - continue; - } + // Site is not bonded, so this won't actually unbind anything + continue; + } - // Get the molecule that will be unbound - Molecule * mol = mapping->getMolecule(); - if (mol == NULL) { - continue; - } - int componentIndex = unbindingTransform->getComponentIndex(); - - // Get the molecule bonded at this site - if ( !mol->isBindingSiteBonded(componentIndex) ) - { - // Site is not bonded, so this won't actually unbind anything - continue; - } - - Molecule * bondedMol = mol->getBondedMolecule(componentIndex); - if ( bondedMol == NULL ) - { - // This shouldn't happen, but skip if it does - continue; - } - - // Check if bondedMol can reach mol through alternative paths - // (i.e., excluding the bond being broken) - if ( !canReachExcludingBond(mol, bondedMol, componentIndex) ) - { - // Good: the molecules will be in different complexes after unbinding - continue; - } - else - { - // Bad: the molecules remain connected through other bonds - // This violates the product-side molecularity constraint - return false; - } + Molecule * bondedMol = mol->getBondedMolecule(componentIndex); + if ( bondedMol == NULL ) + { + // This shouldn't happen, but skip if it does + continue; + } + + int partnerIndex = mol->getBondedMoleculeBindingSiteIndex(componentIndex); + excludedBonds.push_back( make_pair(mol, componentIndex) ); + excludedBonds.push_back( make_pair(bondedMol, partnerIndex) ); + brokenPairs.push_back( make_pair(mol, bondedMol) ); + } + + // Second pass: with every deleted bond removed, each unbound pair must + // end up in different connected components. If any pair is still + // mutually reachable, the products do not actually separate. + for ( unsigned int b = 0; b < brokenPairs.size(); ++b ) + { + if ( canReachExcludingBonds( brokenPairs[b].first, + brokenPairs[b].second, excludedBonds ) ) + { + return false; } } } @@ -894,11 +919,31 @@ MappingSet *TransformationSet::generateBlankMappingSet(unsigned int reactantInde return new MappingSet(mappingSetId, transformations[reactantIndex]); } +bool TransformationSet::isPureContextReactant(unsigned int reactantIndex) const +{ + if (reactantIndex >= n_reactants) return false; + return pureContextReactants.find(reactantIndex) != pureContextReactants.end(); +} + void TransformationSet::finalize() { //Be sure to add at least a blank transformation to every reactant if there is no transformation //specified so that we count the reactants even if we don't do anything to it. for(unsigned int r=0; rgetType() != + (int)TransformationFactory::LOCAL_FUNCTION_REFERENCE) { + pureContext = false; + break; + } + } + if (r < n_reactants && pureContext) { + pureContextReactants.insert(r); + } + if(transformations[r].size()==0) { transformations[r].push_back(TransformationFactory::genEmptyTransform()); MapGenerator *mg = new MapGenerator(transformations[r].size()-1); diff --git a/cpp/nfsim/NFreactions/transformations/transformationSet.hh b/cpp/nfsim/NFreactions/transformations/transformationSet.hh index b690208d..15aa1a97 100644 --- a/cpp/nfsim/NFreactions/transformations/transformationSet.hh +++ b/cpp/nfsim/NFreactions/transformations/transformationSet.hh @@ -4,7 +4,10 @@ #include "../NFreactions.hh" #include +#include #include +#include +#include using namespace std; @@ -308,6 +311,11 @@ namespace NFcore double getSymmetryFactor() const { return symmetryFactor; }; void setSymmetryFactor(double val) { symmetryFactor = val; useSymmetryFactor = true; }; + /* True when the rule does not transform this reactant at all. The + * answer is recorded by finalize() before its EMPTY placeholder is + * appended, because EMPTY also represents a binding partner. */ + bool isPureContextReactant(unsigned int reactantIndex) const; + // To get the connected reactions for each transformation bool checkConnection(ReactionClass * rxn); @@ -328,16 +336,17 @@ namespace NFcore int find(TemplateMolecule *t); /*! - Helper method to check if two molecules remain connected through alternative paths - when a specific bond is removed. This is used to enforce product-side molecularity - for unimolecular unbinding rules (Issue #48). + Helper method to check if two molecules remain connected when a set of bonds is + removed. This is used to enforce product-side molecularity for unimolecular + unbinding rules (Issues #48 and #61). @param mol1 The molecule containing the binding site @param mol2 The bonded partner molecule - @param excludeComponentIndex The component index of the bond to exclude + @param excludedBonds The bond half-edges to exclude from traversal @return true if mol1 can be reached from mol2 through alternative paths, false otherwise - @author Fix for Issue #48 + @author Fix for Issues #48 and #61 */ - bool canReachExcludingBond(Molecule *mol1, Molecule *mol2, int excludeComponentIndex); + bool canReachExcludingBonds(Molecule *mol1, Molecule *mol2, + const std::vector< std::pair > &excludedBonds); /*! Remembers if the finalize function has been called */ bool finalized; @@ -364,6 +373,10 @@ namespace NFcore /*! A vector that holds the actual Transformation objects */ vector *transformations; + /* Reactant indices that are pure context, recorded before finalize() + * appends placeholder EMPTY transformations. */ + set pureContextReactants; + /*! A vector that holds the addMolecule Transformations, because they are handled separately */ vector addMoleculeTransformations; diff --git a/cpp/parser/BNGAstVisitor.cpp b/cpp/parser/BNGAstVisitor.cpp index ac93f1bc..79544610 100644 --- a/cpp/parser/BNGAstVisitor.cpp +++ b/cpp/parser/BNGAstVisitor.cpp @@ -1,17 +1,21 @@ #include "BNGAstVisitor.hpp" #include +#include #include #include #include #include +#include #include #include #include #include +#include #include #include +#include "antlr_compat.hpp" #include #include "generated/BNGLexer.h" @@ -75,6 +79,14 @@ std::string normalizeLegacyBlockHeaders(const std::string& source) { if (matchWord("begin") || matchWord("end")) { skipSpace(); + const auto blockPosition = position; + if (matchWord("function") && hasBoundary()) { + result.append(line, 0, position); + result.push_back('s'); + result.append(line, position, std::string::npos); + return; + } + position = blockPosition; if (matchWord("molecule")) { skipSpace(); if (matchWord("type") && hasBoundary()) { @@ -102,6 +114,337 @@ std::string normalizeLegacyBlockHeaders(const std::string& source) { return result; } +bool isEmptyReactantFunctionDeclaration(const std::string& line) { + const auto comment = line.find('#'); + const auto code = trimCopy( + comment == std::string::npos ? line : line.substr(0, comment)); + static constexpr std::string_view prefix = "reactant_"; + if (code.size() <= prefix.size() + 2 || + code.compare(0, prefix.size(), prefix) != 0) { + return false; + } + + std::size_t position = prefix.size(); + if (code[position] < '1' || code[position] > '9') return false; + ++position; + while (position < code.size() && + std::isspace(static_cast(code[position]))) { + ++position; + } + return code.compare(position, 2, "()") == 0 && position + 2 == code.size(); +} + +// The legacy NFsim XML parser accepts empty reactant_N() declarations as +// placeholders. They carry no user-defined expression; CompositeFunction +// materializes the corresponding mapping count when a reaction evaluates its +// rate. Keep the generated ANTLR grammar strict for ordinary functions and +// erase only these source-backed placeholders before parsing. +std::string normalizeEmptyReactantFunctionDeclarations(const std::string& source) { + std::string result; + result.reserve(source.size()); + bool insideFunctions = false; + + std::size_t lineStart = 0; + while (lineStart <= source.size()) { + const auto lineEnd = source.find('\n', lineStart); + const auto lineLength = lineEnd == std::string::npos + ? source.size() - lineStart + : lineEnd - lineStart; + const auto line = source.substr(lineStart, lineLength); + const auto normalizedHeader = toLower(trimCopy(line)); + if (normalizedHeader == "begin functions") { + insideFunctions = true; + result.append(line); + } else if (normalizedHeader == "end functions") { + insideFunctions = false; + result.append(line); + } else if (insideFunctions && isEmptyReactantFunctionDeclaration(line)) { + result.append("# BNG3: legacy empty reactant-count placeholder"); + } else { + result.append(line); + } + + if (lineEnd == std::string::npos) break; + result.push_back('\n'); + lineStart = lineEnd + 1; + } + return result; +} + +std::string normalizeLegacyActionNames(const std::string& source) { + std::string result; + result.reserve(source.size()); + + bool atLineStart = true; + std::size_t index = 0; + while (index < source.size()) { + if (atLineStart) { + if (source[index] == ' ' || source[index] == '\t' || source[index] == '\r') { + result.push_back(source[index++]); + continue; + } + + if (source[index] == '#') { + const auto lineEnd = source.find('\n', index); + const auto end = lineEnd == std::string::npos ? source.size() : lineEnd; + result.append(source, index, end - index); + index = end; + continue; + } + + constexpr std::array legacyNames = { + "readNetwork", "readModel"}; + bool normalized = false; + for (const auto legacyName : legacyNames) { + if (source.compare(index, legacyName.size(), legacyName) != 0) continue; + const auto afterName = index + legacyName.size(); + std::size_t next = afterName; + while (next < source.size() && + (source[next] == ' ' || source[next] == '\t' || source[next] == '\r')) { + ++next; + } + if (next < source.size() && source[next] == '(') { + result += "readFile"; + index = afterName; + normalized = true; + break; + } + } + if (normalized) { + atLineStart = false; + continue; + } + + atLineStart = false; + } + + const char current = source[index++]; + result.push_back(current); + if (current == '\n') atLineStart = true; + } + + return result; +} + +bool isLooseActionLine(const std::string& line) { + static constexpr std::array actionNames = { + "generate_network", "generatenetwork", "simulate", "simulate_ode", + "simulate_ssa", "simulate_nf", "simulate_pla", "simulate_psa", + "simulate_rm", "simulate_protocol", "writefile", "writexml", + "writesbml", "writesbmlmulti", "writenetwork", "writemodel", + "writemfile", "writemexfile", "writecppfile", "writecpyfile", + "writelatex", "writemdl", "writessc", "writessccfg", + "setconcentration", "addconcentration", "setparameter"}; + + auto trimmed = trimCopy(line); + if (trimmed.empty() || trimmed.front() == '#') return false; + const auto comment = trimmed.find('#'); + if (comment != std::string::npos) trimmed.resize(comment); + + std::size_t nameEnd = 0; + while (nameEnd < trimmed.size() && + (std::isalnum(static_cast(trimmed[nameEnd])) || + trimmed[nameEnd] == '_')) { + ++nameEnd; + } + if (nameEnd == 0) return false; + + auto name = trimmed.substr(0, nameEnd); + std::transform(name.begin(), name.end(), name.begin(), [](unsigned char value) { + return static_cast(std::tolower(value)); + }); + if (std::find(actionNames.begin(), actionNames.end(), name) == actionNames.end()) { + return false; + } + while (nameEnd < trimmed.size() && + std::isspace(static_cast(trimmed[nameEnd]))) { + ++nameEnd; + } + return nameEnd < trimmed.size() && trimmed[nameEnd] == '('; +} + +std::string normalizeLooseActionsInsideModel(const std::string& source) { + std::vector output; + std::vector deferredActions; + bool insideModel = false; + int nestedBlockDepth = 0; + const bool hadTrailingNewline = !source.empty() && source.back() == '\n'; + + std::size_t lineStart = 0; + while (lineStart <= source.size()) { + const auto lineEnd = source.find('\n', lineStart); + const auto lineLength = lineEnd == std::string::npos + ? source.size() - lineStart + : lineEnd - lineStart; + const auto line = source.substr(lineStart, lineLength); + const auto trimmed = trimCopy(line); + std::istringstream words(trimmed); + std::string command; + std::string block; + words >> command >> block; + std::transform(command.begin(), command.end(), command.begin(), [](unsigned char value) { + return static_cast(std::tolower(value)); + }); + std::transform(block.begin(), block.end(), block.begin(), [](unsigned char value) { + return static_cast(std::tolower(value)); + }); + + if (!insideModel) { + output.push_back(line); + if (command == "begin" && block == "model") { + insideModel = true; + nestedBlockDepth = 0; + } + } else if (command == "end" && block == "model") { + output.push_back(line); + output.insert(output.end(), deferredActions.begin(), deferredActions.end()); + deferredActions.clear(); + insideModel = false; + nestedBlockDepth = 0; + } else if (nestedBlockDepth == 0 && isLooseActionLine(line)) { + deferredActions.push_back(line); + } else { + output.push_back(line); + if (command == "begin") { + ++nestedBlockDepth; + } else if (command == "end" && nestedBlockDepth > 0) { + --nestedBlockDepth; + } + } + + if (lineEnd == std::string::npos) break; + lineStart = lineEnd + 1; + } + + output.insert(output.end(), deferredActions.begin(), deferredActions.end()); + + std::ostringstream normalized; + for (std::size_t index = 0; index < output.size(); ++index) { + if (index != 0) normalized << '\n'; + normalized << output[index]; + } + if (hadTrailingNewline) normalized << '\n'; + return normalized.str(); +} + +int maximumIntegerState(const std::string& source) { + int maximum = -1; + for (std::size_t index = 0; index < source.size(); ++index) { + if (source[index] != '~') continue; + std::size_t number = index + 1; + if (number < source.size() && source[number] == '^') { + if (number + 1 >= source.size() || source[number + 1] != '[') continue; + number += 2; + } + if (number >= source.size() || + !std::isdigit(static_cast(source[number]))) { + continue; + } + int value = 0; + while (number < source.size() && + std::isdigit(static_cast(source[number]))) { + const int digit = source[number] - '0'; + if (value > (std::numeric_limits::max() - digit) / 10) { + value = std::numeric_limits::max(); + break; + } + value = value * 10 + digit; + ++number; + } + maximum = std::max(maximum, value); + } + return maximum; +} + +std::vector expandIntegerStateTransition( + const std::string& line, int maximumState) { + const auto increment = line.find("~++"); + const auto decrement = line.find("~--"); + const bool isIncrement = increment != std::string::npos; + const bool isDecrement = decrement != std::string::npos; + if (isIncrement == isDecrement) return {line}; + + const auto transition = isIncrement ? increment : decrement; + const std::string transitionText = isIncrement ? "~++" : "~--"; + if (line.find(transitionText, transition + 1) != std::string::npos) { + return {line}; + } + + const auto boundary = line.rfind("~^[", transition); + if (boundary == std::string::npos || + line.find("~^[", boundary + 1) != std::string::npos) { + return {line}; + } + const auto boundaryEnd = line.find(']', boundary + 3); + if (boundaryEnd == std::string::npos || boundaryEnd >= transition) { + return {line}; + } + + const auto boundText = line.substr(boundary + 3, boundaryEnd - boundary - 3); + if (boundText.empty() || + !std::all_of(boundText.begin(), boundText.end(), [](unsigned char value) { + return std::isdigit(value) != 0; + })) { + return {line}; + } + + std::size_t consumed = 0; + int bound = 0; + try { + bound = std::stoi(boundText, &consumed); + } catch (const std::exception&) { + return {line}; + } + if (consumed != boundText.size()) return {line}; + + const int firstState = isIncrement ? 0 : 1; + const int lastState = isIncrement ? bound - 1 : maximumState; + if (lastState < firstState) return {line}; + constexpr int maxExpandedStates = 10000; + if (lastState > firstState + maxExpandedStates - 1) return {line}; + + std::vector expanded; + expanded.reserve(static_cast(lastState - firstState + 1)); + for (int state = firstState; state <= lastState; ++state) { + auto replacement = line; + replacement.replace(boundary, boundaryEnd - boundary + 1, + "~" + std::to_string(state)); + const auto replacementTransition = replacement.find(transitionText); + if (replacementTransition == std::string::npos) return {line}; + replacement.replace(replacementTransition, transitionText.size(), + "~" + std::to_string(state + (isIncrement ? 1 : -1))); + expanded.push_back(std::move(replacement)); + } + return expanded; +} + +std::string normalizeIntegerStateTransitions(const std::string& source) { + const int maximumState = maximumIntegerState(source); + std::vector output; + const bool hadTrailingNewline = !source.empty() && source.back() == '\n'; + + std::size_t lineStart = 0; + while (lineStart <= source.size()) { + const auto lineEnd = source.find('\n', lineStart); + const auto lineLength = lineEnd == std::string::npos + ? source.size() - lineStart + : lineEnd - lineStart; + const auto line = source.substr(lineStart, lineLength); + const auto expanded = expandIntegerStateTransition(line, maximumState); + output.insert(output.end(), expanded.begin(), expanded.end()); + if (lineEnd == std::string::npos) break; + lineStart = lineEnd + 1; + } + + std::ostringstream normalized; + for (std::size_t index = 0; index < output.size(); ++index) { + if (index != 0) normalized << '\n'; + normalized << output[index]; + } + if (hadTrailingNewline) normalized << '\n'; + return normalized.str(); +} + bool isIdentifierStart(char value) { return std::isalpha(static_cast(value)) || value == '_'; } @@ -467,6 +810,19 @@ std::string getComponentName(BNGParser::Component_defContext* ctx) { return ctx->getText(); } +template +void collectParseContexts(antlr4::tree::ParseTree* node, std::vector& contexts) { + if (node == nullptr) { + return; + } + if (auto* context = dynamic_cast(node)) { + contexts.push_back(context); + } + for (auto* child : node->children) { + collectParseContexts(child, contexts); + } +} + template ast::Expression buildLeftAssociativeExpression( ContextT* ctx, @@ -792,7 +1148,10 @@ ast::Expression parseExpressionImpl(const std::string& exprText) { } // namespace std::string normalizeBNGLSource(const std::string& sourceText) { - return normalizeLegacyBlockHeaders(normalizeTfunSyntax(sourceText)); + return normalizeLooseActionsInsideModel(normalizeIntegerStateTransitions( + normalizeLegacyActionNames( + normalizeEmptyReactantFunctionDeclarations( + normalizeLegacyBlockHeaders(normalizeTfunSyntax(sourceText)))))); } BNGAstVisitor::BNGAstVisitor() @@ -803,11 +1162,43 @@ std::unique_ptr BNGAstVisitor::takeModel() { } std::any BNGAstVisitor::visitProg(BNGParser::ProgContext* ctx) { + predeclareMoleculeTypes(ctx); visitChildren(ctx); currentModel_->getParameters().evaluateAll(); return {}; } +void BNGAstVisitor::predeclareMoleculeTypes(BNGParser::ProgContext* ctx) { + std::vector explicitDefinitions; + collectParseContexts(ctx, explicitDefinitions); + for (auto* definition : explicitDefinitions) { + visitMolecule_type_def(definition); + predeclaredMoleculeTypes_.insert(definition); + } + + std::vector patterns; + collectParseContexts(ctx, patterns); + std::unordered_set explicitNames; + for (auto* definition : explicitDefinitions) { + if (definition->molecule_def() == nullptr) { + continue; + } + explicitNames.insert(getMoleculeName(definition->molecule_def())); + } + + for (auto* pattern : patterns) { + auto fragment = inferMoleculeTypeFromPattern(pattern); + if (explicitNames.find(fragment.getName()) != explicitNames.end()) { + continue; + } + if (auto* existing = currentModel_->findMoleculeType(fragment.getName())) { + existing->mergeInferredComponents(fragment.getComponents()); + } else { + currentModel_->addMoleculeType(std::move(fragment)); + } + } +} + std::any BNGAstVisitor::visitVersion_def(BNGParser::Version_defContext* ctx) { currentModel_->setVersion(stripQuotes(ctx->quoted_string()->getText())); return {}; @@ -871,6 +1262,9 @@ std::any BNGAstVisitor::visitCompartment_def(BNGParser::Compartment_defContext* } std::any BNGAstVisitor::visitMolecule_type_def(BNGParser::Molecule_type_defContext* ctx) { + if (predeclaredMoleculeTypes_.find(ctx) != predeclaredMoleculeTypes_.end()) { + return {}; + } if (ctx->molecule_def() == nullptr) { return {}; } diff --git a/cpp/parser/BNGAstVisitor.hpp b/cpp/parser/BNGAstVisitor.hpp index 27fbcb21..d8a04137 100644 --- a/cpp/parser/BNGAstVisitor.hpp +++ b/cpp/parser/BNGAstVisitor.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include "ast/Model.hpp" #include "generated/BNGParserBaseVisitor.h" @@ -37,8 +38,10 @@ class BNGAstVisitor : public BNGParserBaseVisitor { private: void addAction(const std::string& name, BNGParser::Action_argsContext* args); + void predeclareMoleculeTypes(BNGParser::ProgContext* ctx); std::unique_ptr currentModel_; + std::unordered_set predeclaredMoleculeTypes_; }; std::unique_ptr parseModel(const std::string& sourceText); diff --git a/cpp/parser/PatternGraphBuilder.cpp b/cpp/parser/PatternGraphBuilder.cpp index e3d15890..a7c81963 100644 --- a/cpp/parser/PatternGraphBuilder.cpp +++ b/cpp/parser/PatternGraphBuilder.cpp @@ -1,5 +1,6 @@ #include "PatternGraphBuilder.hpp" +#include #include #include #include @@ -46,12 +47,10 @@ const bng::ast::ComponentType* findComponentType(const bng::ast::MoleculeType& m return nullptr; } -const bng::ast::MoleculeType& ensureInferredMoleculeType(BNGParser::Molecule_patternContext* ctx, bng::ast::Model& model) { - const auto name = moleculePatternName(ctx); - if (const auto* existing = findMoleculeType(model, name)) { - return *existing; - } +} // namespace +bng::ast::MoleculeType inferMoleculeTypeFromPattern(BNGParser::Molecule_patternContext* ctx) { + const auto name = moleculePatternName(ctx); std::vector components; if (auto* componentList = ctx->component_pattern_list()) { for (auto* componentPattern : componentList->component_pattern()) { @@ -63,7 +62,28 @@ const bng::ast::MoleculeType& ensureInferredMoleculeType(BNGParser::Molecule_pat } } - model.addMoleculeType(bng::ast::MoleculeType(name, std::move(components), false)); + // BNG2 sorts inferred species-graph components before creating a new + // molecule type. Keep the source pattern's order for ties (notably + // symmetric sites), but make the canonical name ordering observable to + // both the XML writer and direct NFsim type registration. + std::stable_sort(components.begin(), components.end(), + [](const bng::ast::ComponentType& left, + const bng::ast::ComponentType& right) { + return left.name < right.name; + }); + + return bng::ast::MoleculeType(name, std::move(components), false); +} + +namespace { + +const bng::ast::MoleculeType& ensureInferredMoleculeType(BNGParser::Molecule_patternContext* ctx, bng::ast::Model& model) { + const auto name = moleculePatternName(ctx); + if (const auto* existing = findMoleculeType(model, name)) { + return *existing; + } + + model.addMoleculeType(inferMoleculeTypeFromPattern(ctx)); return model.getMoleculeTypes().back(); } @@ -254,4 +274,3 @@ bool isSpeciesCompartmentPrefix(BNGParser::Species_defContext* ctx) { } } // namespace bng::parser - diff --git a/cpp/parser/PatternGraphBuilder.hpp b/cpp/parser/PatternGraphBuilder.hpp index 83736169..9ef52937 100644 --- a/cpp/parser/PatternGraphBuilder.hpp +++ b/cpp/parser/PatternGraphBuilder.hpp @@ -10,6 +10,11 @@ BNGcore::PatternGraph buildPatternGraph( ast::Model& model, bool treatUnspecifiedBondAsWildcard = false); +/// Convert a molecule pattern into the inferred type fragment it contributes. +/// The parser prepass uses this to collect states before any pattern graph is +/// materialized, matching BNG2's model-wide inference for omitted type blocks. +ast::MoleculeType inferMoleculeTypeFromPattern(BNGParser::Molecule_patternContext* ctx); + std::string extractSpeciesCompartment(BNGParser::Species_defContext* ctx); bool isSpeciesCompartmentPrefix(BNGParser::Species_defContext* ctx); diff --git a/cpp/parser/antlr_compat.hpp b/cpp/parser/antlr_compat.hpp new file mode 100644 index 00000000..ad71132b --- /dev/null +++ b/cpp/parser/antlr_compat.hpp @@ -0,0 +1,20 @@ +#pragma once + +// Windows SDK headers define ERROR as a numeric GDI status constant. ANTLR4 +// uses ERROR, TRUE, and FALSE as enum members, so those macros must be removed +// before any ANTLR runtime or generated parser header is included. Some +// SDK/toolchain combinations also expose the older lowercase spelling. +#if defined(_WIN32) +# if defined(ERROR) +# undef ERROR +# endif +# if defined(TRUE) +# undef TRUE +# endif +# if defined(FALSE) +# undef FALSE +# endif +# if defined(constant) +# undef constant +# endif +#endif diff --git a/cpp/parser/generated/BNGLexer.h b/cpp/parser/generated/BNGLexer.h index 01229bc6..181643f2 100644 --- a/cpp/parser/generated/BNGLexer.h +++ b/cpp/parser/generated/BNGLexer.h @@ -4,6 +4,7 @@ #pragma once +#include "../antlr_compat.hpp" #include "antlr4-runtime.h" @@ -99,4 +100,3 @@ class BNGLexer : public antlr4::Lexer { bool FLOATSempred(antlr4::RuleContext *_localctx, size_t predicateIndex); }; - diff --git a/cpp/parser/generated/BNGParser.h b/cpp/parser/generated/BNGParser.h index e971b221..ccbb08e7 100644 --- a/cpp/parser/generated/BNGParser.h +++ b/cpp/parser/generated/BNGParser.h @@ -4,6 +4,7 @@ #pragma once +#include "../antlr_compat.hpp" #include "antlr4-runtime.h" @@ -2283,4 +2284,3 @@ class BNGParser : public antlr4::Parser { private: }; - diff --git a/cpp/parser/generated/BNGParserBaseListener.h b/cpp/parser/generated/BNGParserBaseListener.h index c03a8e0c..40a7e2d0 100644 --- a/cpp/parser/generated/BNGParserBaseListener.h +++ b/cpp/parser/generated/BNGParserBaseListener.h @@ -4,6 +4,7 @@ #pragma once +#include "../antlr_compat.hpp" #include "antlr4-runtime.h" #include "BNGParserListener.h" @@ -320,4 +321,3 @@ class BNGParserBaseListener : public BNGParserListener { virtual void visitErrorNode(antlr4::tree::ErrorNode * /*node*/) override { } }; - diff --git a/cpp/parser/generated/BNGParserBaseVisitor.h b/cpp/parser/generated/BNGParserBaseVisitor.h index a799b0db..61425be9 100644 --- a/cpp/parser/generated/BNGParserBaseVisitor.h +++ b/cpp/parser/generated/BNGParserBaseVisitor.h @@ -4,6 +4,7 @@ #pragma once +#include "../antlr_compat.hpp" #include "antlr4-runtime.h" #include "BNGParserVisitor.h" @@ -429,4 +430,3 @@ class BNGParserBaseVisitor : public BNGParserVisitor { }; - diff --git a/cpp/parser/generated/BNGParserListener.h b/cpp/parser/generated/BNGParserListener.h index ef0ace13..da7c29e5 100644 --- a/cpp/parser/generated/BNGParserListener.h +++ b/cpp/parser/generated/BNGParserListener.h @@ -4,6 +4,7 @@ #pragma once +#include "../antlr_compat.hpp" #include "antlr4-runtime.h" #include "BNGParser.h" @@ -313,4 +314,3 @@ class BNGParserListener : public antlr4::tree::ParseTreeListener { }; - diff --git a/cpp/parser/generated/BNGParserVisitor.h b/cpp/parser/generated/BNGParserVisitor.h index 6c268fb3..56e2dffb 100644 --- a/cpp/parser/generated/BNGParserVisitor.h +++ b/cpp/parser/generated/BNGParserVisitor.h @@ -4,6 +4,7 @@ #pragma once +#include "../antlr_compat.hpp" #include "antlr4-runtime.h" #include "BNGParser.h" @@ -227,4 +228,3 @@ class BNGParserVisitor : public antlr4::tree::AbstractParseTreeVisitor { }; - diff --git a/docs/BNG3_CONVERGENCE_DONE_CHECKLIST.md b/docs/BNG3_CONVERGENCE_DONE_CHECKLIST.md new file mode 100644 index 00000000..53fdfc0f --- /dev/null +++ b/docs/BNG3_CONVERGENCE_DONE_CHECKLIST.md @@ -0,0 +1,3423 @@ +# BNG3 Convergence: Definition of Done and Remaining Checklist + +**Status:** Active; not complete +**Last audited:** 2026-09-03 +**Repository:** RuleWorld/BNG3 +**Working branch:** codex/bng3-integration-foundations +**Audited semantic code head:** 094f7ac62a2baae0abebfcac134f558a324a6744 +**Checklist refresh base:** 094f7ac62a2baae0abebfcac134f558a324a6744 (public exact-head checkpoint for the Playground-derived Atomizer naming-convention configuration and facade option) +**Latest workflow checkpoint:** 094f7ac62a2baae0abebfcac134f558a324a6744 (hosted checks read back after push; CI [33767541435](https://github.com/RuleWorld/BNG3/actions/runs/33767541435), CodeQL [33767541537](https://github.com/RuleWorld/BNG3/actions/runs/33767541537), and formatting [33767541467](https://github.com/RuleWorld/BNG3/actions/runs/33767541467) remain queued) +**PR:** RuleWorld/BNG3#2 +**Independent implementation reference:** RuleWorld/bngplayground Atomizer +**Energy-evaluator source reference:** akutuva21/nfsim PR #475, merged at +6690fda5d9e053df822d0248ebae185f5caca82a; accepted energy-source cutoff +3b046fc1b9f76719d92be22279b24992cdae7c35. The public +`akutuva21/nfsim` fork currently has master at +`c51c7a34128d188189485bd318aeae4d936bcb29` (observed 2026-09-01); later +non-energy PRs #476 and #477 are deliberately not silently included in the +BNG3 port. + +This is the execution checklist for the BNG3 convergence goal. It turns the +completion charter and Section 11 of BNG3_INTEGRATION_PLAN.md into auditable +work items. The unification work orders in BNG3_unification_spec.md remain the +detailed dependency map; provenance/capability-matrix.yml remains the +capability inventory. + +The project is done only when every mandatory item below is checked or has an +explicitly approved compatibility disposition. A green unit suite, a green +PR, a partial port, a documented limitation, or a known mismatch is not +completion. + +## How to use this checklist + +- Use [x] only when current evidence is attached by path, command output, + artifact digest, hosted check, or maintainer decision. +- Use [ ] for work that is absent, partial, unverified, or awaiting approval. +- Treat a compatibility disposition as valid only when it has an owner, + rationale, affected interfaces, migration path, release-note entry, contract + test, and review/expiry date. +- Add the source revision, BNG3 commit, test/fixture, oracle, and gate to every + completed semantic item. +- Never widen tolerances, broaden skips, regenerate goldens silently, or change + the exception ledger merely to make this file easier to check. +- Re-audit the whole checklist on the exact release-candidate SHA. Earlier + evidence is stale after a rebase, autofix, merge, or semantic change. + +## Current verified checkpoint + +These items describe the current checkpoint. They do not satisfy the full +completion gate. + +- [x] Required fast-forward pull completed before this documentation change. +- [x] Historical semantic checkpoint + `44d8655d3b0d838dc33420c0d7800c12bb465785` passes the full local + Release/Ninja CTest gate and the legacy Macro security contract. The + current semantic checkpoint supersedes it and requires fresh exact-head + evidence. +- [x] The previous local semantic checkpoint was + `ead6b8e1513f819ec91571aa0e5ead49aa119a8c`; its parent + `7705c488c3ce4e1e64b47b001146f981b9e68d48` is the tests-first + reverse-rate derivation checkpoint. `ead6b8e` makes the compartment-aware + species deduplication guard semantic rather than serialization-order based. + The source-derived cBNGL iteration-3 contract passes (`16` species), and + the full root cBNGL fixture converges to `78` species and `354` reactions, + matching `tests/validation/Validate/DAT_validate/Motivating_example_cBNGL.net`. +- [x] The previous public code/test checkpoint was + `ead6b8e1513f819ec91571aa0e5ead49aa119a8c`; it includes the source-derived + cBNGL compartment-deduplication regression and is the exact public branch + head read back with `gh api` before the Macro checkpoint. +- [x] The previous local semantic checkpoint is + `4edf4df57f01d22f15d83ee6635b82e974b0e6dc`; it completes the previously + unlinked `MacroBNGModel::trans_specie` source port, wires the source + `pre_rules`/`pre_obs1` pipeline, and ports the accepted `num_site` + allocation rewrite. Its source-derived Macro contract passes in + `tests/cpp/test_network_generator.cpp`. +- [x] The previous public code/test checkpoint is + `4edf4df57f01d22f15d83ee6635b82e974b0e6dc`; `gh api` and `gh pr view 2` + agreed on the exact public branch/PR head before this semantic checkpoint. +- [x] The latest local semantic checkpoint is + `88f4e548ed8b7ef43cfc57aa62ad7b7914205613`; it ports the accepted NFsim + `4bb24b3119684e9ec6e870bb4b517866e2aa15a4` cached-old-propensity lookup + for implicit sparse selector batches and adds a source-derived fired-event + regression in `tests/cpp/test_nfsim_ast_adapter.cpp`. +- [x] The latest public code/test checkpoint is + `88f4e548ed8b7ef43cfc57aa62ad7b7914205613`; `gh api` and `gh pr view 2` + agree on the exact public branch/PR head after push. +- [x] The latest local semantic checkpoint is + `7241746a50ed0f87f23ad93eda38b2a9e7cca180`; it ports the accepted-cutoff + NFsim `301bfbeb5ec5007532f713f488ff9954da9ebe1f` guarded Release-LTO probe + and applies the detected IPO property to embedded NFsim and its built + consumers. The source-derived CMake contract is + `tests/cpp/test_release_lto.cmake`. +- [x] The latest public semantic code/test checkpoint is + `7241746a50ed0f87f23ad93eda38b2a9e7cca180`; exact `gh api` branch and + `gh pr view 2` readback agree, and PR #2 remains open. +- [x] The latest local semantic checkpoint is + `413523620b1903f7152a27ee6441b0b4d07b933b`; it ports Playground commit + `9bbad7b63968c18ccd1936c664e681360dd0b014` by adding an opt-in + `keep_parameterized` path to `modern.write_functions`, with sanitized + formal arguments and source-derived coverage in + `tests/python/test_modern_atomizer_writer_parameters.py`. +- [x] The latest public semantic code/test checkpoint is + `413523620b1903f7152a27ee6441b0b4d07b933b`; exact `gh api` branch and + `gh pr view 2` readback agree, and PR #2 remains open. +- [x] A bounded audit of the Python-relevant Playground writer fixes at source + commits `d95fe58c65751135f76a18034039105f5eaf7f0`, + `98ca8f3bd215ec97babba528b9c768246f3d9b2f`, + `9f2044089cc30e72ea4e346ea18de69466455286`, and + `9bbad7b63968c18ccd1936c664e681360dd0b014` found no unported supported + slice: assignment-rule/seed resolution and time-rate wrapping are covered + by the earlier modern-writer ports, reaction-flux inlining is covered by + `9124fc5`, two-argument `log(base,value)` conversion is present in + `modern/writer.py`, and `keepParameterized` is covered by `4135236` and + `tests/python/test_modern_atomizer_writer_parameters.py`. This is a source + reconciliation note only; comprehensive writer, parser, SBML-writer, and + independent round-trip parity remain open. +- [x] Accepted-cutoff NFsim commit + `59423016cb2b30ac2dbc058bf8ef7cc5efb6bdf3` (small-`Km` Michaelis-Menten + root-stability fix) is represented equivalently by BNG3 implementation + `4dac1c74977493718b2ef7891bddad8a07842548` and its source-derived + red-first regression `d7510f4f95e6af9e135b6fe361458bfb400f9251` in + `tests/cpp/test_nfsim_ast_adapter.cpp`. This closes that accepted source + slice only; independent full NFsim energy parity, benchmark provenance, and + the remaining evaluator reconciliation gates stay open. +- [x] The applicable legacy function-dependency-cycle repair from the + user-owned non-main source commit + `dca95a6aa80e249f1adf981037b61bf020f7b5ad` is ported exactly in + `legacy/perl/Perl2/ParamList.pm` and covered by the source-derived + `tests/python/test_legacy_function_cycles.py`. The red-first fixture now + fails cleanly with `Function dependency cycle` rather than Perl deep + recursion; the focused test reports `1 passed`, the Perl syntax check + reports `syntax OK`, and the full Python suite reports `242 passed, 27 + skipped, 8 warnings`. This qualifies only the bounded cycle-detection slice; + the remaining legacy/API deletion, serializer, parser, and independent + BNG2 parity work stays open. Public branch and PR #2 read back to exact + code head `1ff6d7bb9f0199ac8be09fa35174e0cd81e8a548`; CI + [33580037044](https://github.com/RuleWorld/BNG3/actions/runs/33580037044), + formatting [33580037040](https://github.com/RuleWorld/BNG3/actions/runs/33580037040), + and CodeQL [33580037037](https://github.com/RuleWorld/BNG3/actions/runs/33580037037) + were queued at readback, so they are not completion evidence. +- [x] The legacy CVODE export repair from the same user-owned source commit + `dca95a6aa80e249f1adf981037b61bf020f7b5ad` is ported in + `legacy/perl/Perl2/Expression.pm`: `toCVodeString` now maps BNGL `~=` and + `~` to C `!=` and `!`, uses numeric arity comparison for `sum`/`avg`, and + reports its own CVODE diagnostic for anonymous-function expansion. The + source-derived `tests/python/test_legacy_cvode_export.py` passes after the + red-first export retained `~=`/`~`; focused legacy coverage reports `1 + passed`, Perl syntax reports `syntax OK`, exact-tree CTest reports `185/185`, + and the full Python suite reports `243 passed, 27 skipped, 8 warnings`. + This qualifies only the bounded CVODE-export slice; complete legacy/API + retirement, serializer coverage, and independent BNG2 parity remain open. + Public branch and PR #2 read back to exact code head + `772adf55a8a8dafa1d49fabd940eec38fbf26187`; CI + [33580496563](https://github.com/RuleWorld/BNG3/actions/runs/33580496563), + formatting [33580496561](https://github.com/RuleWorld/BNG3/actions/runs/33580496561), + and CodeQL [33580496583](https://github.com/RuleWorld/BNG3/actions/runs/33580496583) + were queued at readback, so they are not completion evidence. +- [x] The legacy pattern-modifier/XML repair from the same user-owned source + commit `dca95a6aa80e249f1adf981037b61bf020f7b5ad` is ported in + `legacy/perl/Perl2/Molecule.pm` and `legacy/perl/Perl2/SpeciesGraph.pm`: + graph-level `{MatchOnce|Fixed}` modifiers now survive molecule parsing when + adjacent to the final molecule or separated by whitespace, and XML + quantifier relations escape `<`, `>`, `<=`, and `>=`. The exact source + fixtures are covered by `tests/python/test_legacy_pattern_xml.py`; focused + coverage reports `2 passed`, both touched Perl modules report `syntax OK`, + exact-tree CTest reports `185/185`, and the full Python suite reports `245 + passed, 27 skipped, 8 warnings`. This qualifies only the bounded + pattern/XML slice; complete legacy/API retirement, all serializer/parser + compatibility, and independent BNG2 parity remain open. Public branch and + PR #2 read back to exact code head + `d1602bab0215c72352a21b10d0f6c54f396c88dd`; CI + [33580739835](https://github.com/RuleWorld/BNG3/actions/runs/33580739835), + formatting [33580739836](https://github.com/RuleWorld/BNG3/actions/runs/33580739836), + and CodeQL [33580739837](https://github.com/RuleWorld/BNG3/actions/runs/33580739837) + were queued at readback, so they are not completion evidence. +- [x] The legacy reactant-pattern symmetry correction from source commit + `dca95a6aa80e249f1adf981037b61bf020f7b5ad` is ported in + `legacy/perl/Perl2/RxnRule.pm`. Aggregate-graph automorphisms are now + filtered to preserve complete reactant-pattern boundaries before statistical + factor calculation. The exact `issue_090_implicit_bonds` source fixture is + covered by `tests/python/test_legacy_symmetry_factor.py`; red-first output + was `0.25*_rateLaw1`, and the repaired output is `0.5*_rateLaw1`. Focused + legacy coverage reports `1 passed`, all touched Perl modules report `syntax + OK`, exact-tree CTest reports `185/185`, and the full Python suite reports + `246 passed, 27 skipped, 8 warnings`. This qualifies only the bounded + symmetry-factor slice; complete legacy/API retirement, broader reaction + parity, and independent BNG2 evidence remain open. Public branch and PR #2 + read back to exact code head + `7b6feae0c4052ad249a5b166e9b8c7f14347cf0a`; CI + [33581009534](https://github.com/RuleWorld/BNG3/actions/runs/33581009534), + formatting [33581009537](https://github.com/RuleWorld/BNG3/actions/runs/33581009537), + and CodeQL [33581009550](https://github.com/RuleWorld/BNG3/actions/runs/33581009550) + were queued at readback, so they are not completion evidence. +- [x] The legacy console error-routing repair from source commit + `dca95a6aa80e249f1adf981037b61bf020f7b5ad` is ported in + `legacy/perl/Perl2/BNGUtils.pm` and `legacy/perl/Perl2/Console.pm` by + exporting and using `send_error` for command/action failures while retaining + warnings for unrecognized input. The source-derived + `tests/python/test_legacy_console_streams.py` proves the red-first + `WARNING:`/stdout behavior is replaced by `ERROR:`/stderr; focused coverage + reports `1 passed`, touched Perl modules report `syntax OK`, exact-tree CTest + reports `185/185`, and the full Python suite reports `247 passed, 27 + skipped, 8 warnings`. This qualifies only the bounded console stream slice; + complete legacy/API retirement, CLI parity, and independent BNG2 evidence + remain open. Public branch and PR #2 read back to exact code head + `4d403ecc5c1627340f9c259cb7d9be745fbb814c`; CI + [33581281331](https://github.com/RuleWorld/BNG3/actions/runs/33581281331), + formatting [33581281347](https://github.com/RuleWorld/BNG3/actions/runs/33581281347), + and CodeQL [33581281317](https://github.com/RuleWorld/BNG3/actions/runs/33581281317) + were queued at readback, so they are not completion evidence. +- [x] The legacy NetworkGraph synthetic-zero trimming repair from source + commit `dca95a6aa80e249f1adf981037b61bf020f7b5ad` is ported in + `legacy/perl/Perl2/Visualization/NetworkGraph.pm`. Empty rule-side zeros are + removed only at the `->` boundary, preserving molecule labels that begin or + end with `0`. The source-derived + `tests/python/test_legacy_network_graph_labels.py` reports `1 passed` after + red-first corruption of `0A`/`B0`; the touched Perl module reports `syntax + OK`, exact-tree CTest reports `185/185`, and the full Python suite reports + `248 passed, 27 skipped, 8 warnings`. This qualifies only the bounded graph + label slice; complete visualization/API parity, legacy retirement, and + independent BNG2 evidence remain open. Public branch and PR #2 read back to + exact code head `99e99506cea1733c6a7e8dfe90531ec0f826c318`; CI + [33581519666](https://github.com/RuleWorld/BNG3/actions/runs/33581519666), + formatting [33581519687](https://github.com/RuleWorld/BNG3/actions/runs/33581519687), + and CodeQL [33581519622](https://github.com/RuleWorld/BNG3/actions/runs/33581519622) + were queued at readback, so they are not completion evidence. +- [x] The bounded legacy diagnostic/output repairs from source commit + `dca95a6aa80e249f1adf981037b61bf020f7b5ad` are ported in + `legacy/perl/Perl2/BNGAction.pm` and `legacy/perl/Perl2/BNGOutput.pm`: + `retrieve` and `Writing` diagnostics are corrected, `QueryNames.m` uses an + explicit three-argument open with error propagation, and generated MATLAB + comments use `names`/`species`. The source-derived + `tests/python/test_legacy_output_contract.py` reports `2 passed`; all + touched legacy Perl modules report `syntax OK`; exact-tree CTest reports + `185/185`; and the full Python suite reports `250 passed, 27 skipped, 8 + warnings`. This qualifies only the bounded output/diagnostic slice; + complete legacy/API retirement, serializer/parser parity, packaging, and + independent BNG2 evidence remain open. Public branch and PR #2 read back to + exact semantic code head + `bf2900b330868e78ca2833a6d6bd9d977859d2b9`; CI + [33581941200](https://github.com/RuleWorld/BNG3/actions/runs/33581941200), + formatting + [33581941209](https://github.com/RuleWorld/BNG3/actions/runs/33581941209), + and CodeQL + [33581941228](https://github.com/RuleWorld/BNG3/actions/runs/33581941228) + were queued at readback, so they are not completion evidence. +- [x] The bounded legacy Macro site-count repair from source commit + `dca95a6aa80e249f1adf981037b61bf020f7b5ad` is ported in + `legacy/perl/Perl2/MacroBNGModel.pm`: the `pre_species1` duplicate-site + count now uses numeric `>` rather than lexicographic `gt`. The source-derived + `tests/python/test_legacy_macro_numeric_compare.py` failed red-first on the + old operator and passes after the port; the focused legacy suite reports + `3 passed`, the Macro C++ contracts report `2/2`, the touched Perl module + reports `syntax OK`, exact-tree CTest reports `185/185`, and the full Python + suite reports `251 passed, 27 skipped, 8 warnings`. This qualifies only the + bounded Macro comparison slice; complete legacy/API parity, serialization, + packaging, and independent BNG2 evidence remain open. Public branch and PR + #2 read back to exact semantic code head + `7977966724246626a7c22c99489d99074427d8d6`; CI + [33582369743](https://github.com/RuleWorld/BNG3/actions/runs/33582369743), + formatting + [33582369701](https://github.com/RuleWorld/BNG3/actions/runs/33582369701), + and CodeQL + [33582369686](https://github.com/RuleWorld/BNG3/actions/runs/33582369686) + were queued at readback, so they are not completion evidence. +- [x] The latest CI truthfulness repair checkpoint is + `e7cd59bd793a30d31bf2b8822725e05ba097b3d0`; it makes weekly C++/Perl + cross-validation fail closed on engine, output, or corpus errors and adds + the source-derived contract `test_weekly_cross_validation_fails_closed_on_engine_or_output_errors`. + The focused local contract reports `8 passed`; exact branch and PR + readback agree and PR #2 remains open. Hosted CI + [33575648537](https://github.com/RuleWorld/BNG3/actions/runs/33575648537), + formatting [33575648574](https://github.com/RuleWorld/BNG3/actions/runs/33575648574), + and CodeQL [33575648533](https://github.com/RuleWorld/BNG3/actions/runs/33575648533) + are queued for this exact SHA; queued status is not completion evidence. +- [x] The latest strict-reference validation checkpoint is + `0e2642aa239c569f66eb550db6c0952219060142`; `scripts/validate.py` now has + `--strict-references`, and the PR/weekly reference jobs use it while listing + the current 35 known exclusions explicitly. The exact local validation + subset reports `36` structural reference passes, `35` explicit exclusions, + and `0` errors; the source-derived CI contracts report `10 passed`. + This makes future unlisted missing `.net` oracles fail closed; it does not + close the excluded validation or provenance gaps. Hosted CI + [33577070603](https://github.com/RuleWorld/BNG3/actions/runs/33577070603), + formatting [33577070621](https://github.com/RuleWorld/BNG3/actions/runs/33577070621), + and CodeQL [33577070593](https://github.com/RuleWorld/BNG3/actions/runs/33577070593) + are queued for this exact SHA. +- [x] Exact-head strict-reference validation at semantic code checkpoint + `277e0b66a3bb911ed2faf1f069975cdb2108c592` used + `PYTHONPATH=python:build/cpp python scripts/validate.py --bng-cpp + build/cpp/bng_cpp --strict-references --skip-file + tests/validation/reference_exclusions.json --skip-profile pull_request + --verbose` and reports `40` passes, `0` failures, `0` errors, and `31` + explicit skips. The current `build/cpp/bng_cpp` artifact has SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + This is current local structural NET evidence only; the 31 explicitly + excluded models, independent-oracle/provenance requirements, and complete + Tier-P/Tier-NF parity remain open. The semantic checkpoint's hosted CI + [33592147684](https://github.com/RuleWorld/BNG3/actions/runs/33592147684), + CodeQL [33592147712](https://github.com/RuleWorld/BNG3/actions/runs/33592147712), + and formatting [33592147725](https://github.com/RuleWorld/BNG3/actions/runs/33592147725) + were queued at exact-head readback. +- [x] Historical published CI-repair checkpoint is + `9a2475a0af360d685dc41eb9bb376f6517d74b4d`; `gh api` and `gh pr view 2` + agreed on this branch/PR source head immediately after push, and PR #2 + remains open. +- [x] The small documentation grammar fix remains the only unrelated tracked + BNG3 worktree modification. It remains intentionally unstaged and must not + be mixed into semantic or checklist commits. +- [x] The current checklist evidence checkpoint + `85f2aff31865f9dfaba2147e6bccd8e791157a3f` is the exact public branch and + PR #2 head read back with `gh api` and `gh pr view`; PR #2 remains open. Its + exact-head CI + [33583845197](https://github.com/RuleWorld/BNG3/actions/runs/33583845197), + Formatting patch + [33583845090](https://github.com/RuleWorld/BNG3/actions/runs/33583845090), + and CodeQL + [33583845085](https://github.com/RuleWorld/BNG3/actions/runs/33583845085) + were queued at readback, so none is completion evidence yet. +- [x] Historical exact-head CTest passes `177/177` on `73757ea` (local + Release/Ninja build; `ctest --test-dir build --output-on-failure`), including the exact + compartment-aware dedup contract, compact ODE derivative contract, empty + graph, exact Node serialization, t4 rejection contract, inferred-state/ + type-order gates, and IfTest parity assertions. +- [x] Historical semantic checkpoint `d502e47` passes the full local + Release/Ninja CTest gate: `178/178` from `ctest --test-dir build + --output-on-failure`, including the user-defined ODE-rate contract. +- [x] Current semantic checkpoint `4ea7157` passes the full local Release/Ninja + CTest gate: `180/180` from `ctest --test-dir build --output-on-failure`, + including the source-derived multi-pattern ODE and repeated-pattern NetWriter + contracts. This evidence qualifies `4ea7157` only; later semantic or + documentation heads require fresh exact-head readback. +- [x] Current semantic checkpoint `1c03bc1` passes the full local Release/Ninja + CTest gate: `181/181` from `ctest --test-dir build --output-on-failure`, + including the source-derived CLI action-error contract. This evidence + qualifies `1c03bc1` only; later semantic or documentation heads require + fresh exact-head readback. +- [x] Historical semantic checkpoint `44d8655` passes the full local + Release/Ninja CTest gate: `181/181`; the full Python suite passes `237` + tests with `27` skips and `8` warnings, and the legacy security contract + passes `2/2` with `perl -c legacy/perl/Perl2/MacroBNGModel.pm` reporting + syntax OK. +- [x] Exact-head local gates pass at `ead6b8e`: `ctest --test-dir build + --output-on-failure` reports `100% tests passed out of 181`, and + `PYTHONPATH=build/cpp:python python -m pytest tests/python -q` reports + `240 passed, 27 skipped, 8 warnings` in `15.83s`. These local results do + not substitute for terminal hosted checks or independent full-corpus + parity. +- [x] Exact-head local gates pass at `4edf4df`: `ctest --test-dir build + --output-on-failure` reports `100% tests passed out of 183`, including the + two source-derived MacroBNGModel contracts, and + `PYTHONPATH=build/cpp:python python -m pytest tests/python -q` reports + `240 passed, 27 skipped, 8 warnings` in `11.81s`. These local results do + not substitute for terminal hosted checks or independent full-corpus + parity. +- [x] Exact-head local gates pass at `88f4e54`: `ctest --test-dir build + --output-on-failure` reports `100% tests passed out of 184`, including the + source-derived cached sparse-selector batch contract; + `PYTHONPATH=build/cpp:python python -m pytest tests/python -q` reports + `240 passed, 27 skipped, 8 warnings` in `11.28s`; and + `PYTHONPATH=build/cpp:python python -m pytest tests/test_ci_contract.py -q` + reports `7 passed`. These local results do not substitute for terminal + hosted checks or independent full-corpus parity. +- [x] Exact-head local gates pass at `7241746`: the default Release/Ninja + build with `NFSIM_ENABLE_LTO=ON` completes and `ctest --test-dir build + --output-on-failure` reports `100% tests passed out of 185`, including the + source-derived LTO contract; the same contract passes with explicit + `NFSIM_ENABLE_LTO=OFF`; the optional `BUILD_NFSIM_CLI=ON` standalone NFsim + target also builds and passes that contract; Python reports `240 passed, + 27 skipped, 8 warnings`; the CI contracts report `7 passed`; Black reports + `178 files would be left unchanged`; Ruff and `git diff --check` pass. + These local results do not substitute for terminal hosted checks or + independent full-corpus parity. +- [x] The CI truthfulness repair test gate at `e7cd59b` reports `8 passed` via + `PYTHONPATH=build/cpp:python python -m pytest tests/test_ci_contract.py -q`; + `git diff --check` is clean. This verifies the workflow contract locally, + not hosted execution or full weekly validation. +- [x] The strict-reference validation gate at `0e2642a` reports `10 passed` + for the source-derived CI contracts; the supported local reference subset + reports `36` passes, `35` explicit exclusions, and `0` errors. This is + truthful coverage accounting, not complete Tier-P parity. +- [x] Exact-head deletion and ODE-validation checkpoint + `5933584c3ae26690b35612bd589eeff37f37822a` is grounded in diagnostic BNG2 + source revision `fde0cd6a522c9f988d5495db31c70ce0f98e744b`: its + `src/core/Transformation.cpp:397-410` `DeleteBond::transform` deletes the + bound bond and restores two explicit `UNBOUND` endpoints, while + `bng2/Perl2/Component.pm:153-188` omits an unbound edge marker from the + serialized component. BNG3 now restores markers only for surviving endpoints + in `cpp/ast/ReactionRule.cpp:495-559`; deletion endpoints are excluded from + the restoration to prevent orphan marker-only species. The source-derived + regression is `tests/cpp/test_reaction_rule_expansion.cpp:103-140`. + Red-first focused execution reported `targetCount 2` before restoration and + a marker-only species after the initial broad restoration; the final focused + test reports `5 assertions in 1 test case`. The action-aware repair in + `tests/validation/test_parity_ode.py:1-43` runs the model action block through + the CLI, preserving setup actions that the direct API path discarded. The + focused Motivation NET parity reports `2 passed`; focused action-aware ODE + parity reports `1 passed`; full BNG2-backed smoke reports `17 passed, 2 + skipped` (both `gene_expr` because the external oracle cannot find NFsim); + the complete native CTest gate reports `186/186`; Python reports `276 passed, + 27 skipped, 9 warnings`; Ruff and Black pass. One hundred isolated + `Motivating_example` CLI runs all exit `0` and produce `78` species; the + BNG2/B3 semantic NET comparator passes, with temporary NET digests + `5ed6ddfd25b770bd7614f689e277d9ce6dd2371f293468bca10336d4a38d3025` and + `8ee09b27cda0908c2dfd8039712b5c40fe59b16196380a1cb4de2d1395b2680a`. + The rebuilt `build/cpp/bng_cpp` digest is + `0f2b5d1357ed700241bb7326adebb546f0c51d249c58a3a2bc05b14523848227`. + Public PR #2 readback agrees on this exact open head; CI + [33642690870](https://github.com/RuleWorld/BNG3/actions/runs/33642690870), + CodeQL [33642690911](https://github.com/RuleWorld/BNG3/actions/runs/33642690911), + and formatting [33642690862](https://github.com/RuleWorld/BNG3/actions/runs/33642690862) + were queued at readback and are not completion evidence. This closes only + the deletion/validator defects; broad NET/ODE parity, independent oracle + provenance, and the remaining checklist gaps stay open. +- [x] Fresh independent native-NFsim validation was rerun against semantic + head `5933584c3ae26690b35612bd589eeff37f37822a` using the absolute oracle + `/Users/akutuva/Documents/BioNetGen/nfsim/build/NFsim`, whose SHA-256 is + `7302fe29b16d1ebe86369f752f2a49d2c87ef16539faaec11b82294a9fa56d22`. + `test_nf_vs_native` used the committed four-model Tier-NF selection + (`simple_system`, `tlbr`, `motor`, `localfunc`), 200 runs per model, + seeds `1..200`, `t_end=50`, 50 output steps, and four isolated workers: + `4 passed, 1 warning in 158.85s`. + The current direct-vs-in-memory-XML localfunc contract reports `2 passed`, + and the exact seeded native endpoint contract for motor/tlbr reports + `2 passed` using the same oracle. This is fresh subset evidence only: + TQSSA has no approved fixture in this checkout, and protocol-NF, + three-way construction parity, and the full approved Tier-NF gate remain + open. +- [x] Fresh diagnostic BNG2-backed non-slow parity at semantic head + `5933584c3ae26690b35612bd589eeff37f37822a` used the isolated Perl/native + oracle at source revision + `fde0cd6a522c9f988d5495db31c70ce0f98e744b` and the command + `env BNG2_PERL=/private/tmp/bng2-oracle.TToh58/source/bng2/BNG2.pl + PYTHONPATH=python:build/cpp python -m pytest -c + tests/validation/pytest.ini tests/validation -m "parity and not slow" + --bng-cpp build/cpp/bng_cpp -q`: `54 passed, 46 skipped, 94 deselected` + in `110.02s`. No test failed; skips remain explicit for unavailable + legacy/oracle inputs and assets. This improves current differential + evidence but does not establish complete Tier-P parity or approve the + diagnostic oracle as the release oracle. +- [x] The source-derived modern Atomizer gates at `4135236` report `76 passed` + across the modern Atomizer test modules, and the full Python suite reports + `241 passed, 27 skipped, 8 warnings`. This qualifies the Python semantic + checkpoint only; independent oracle parity, full writer coverage, and + hosted checks remain open. +- [x] Diagnostic independent BNG2 execution is now available from an isolated + clone of source revision `fde0cd6a522c9f988d5495db31c70ce0f98e744b` using + the repository's `bng2/Makefile`; the arm64 `run_network` artifact has + SHA-256 `0dcde86b0e29a05e1af9ea1fb027cf2441641fd297906701ada37343c442977a`. + A copied `simple_system` fixture generated a NET and completed CVODE through + that binary. This is diagnostic evidence only: the source revision is not + yet the approved lock cutoff and the temporary build is not a retained + oracle artifact. +- [x] With that diagnostic BNG2 Perl/native oracle, the non-slow Tier-P NET + parity command produced `50 passed, 46 skipped, 4 failed, 10 deselected` in + `342.61s`. The four failures are concrete open gaps: a 180-second + `Motivating_example_cBNGL` generation timeout, Repressilator degradation-rate + mismatch, NFKB illustrating-protocol expression-rate serialization mismatch, + and BNG3 rejection of the legacy `test_time` `f_correct` parameter. The + skips remain honest where BNG2 cannot process legacy syntax/assets or lacks + NFsim; this historical run does not qualify complete Tier-P parity. Targeted + repairs now cover the four recorded signatures (`ead6b8e`, `418db22`, + `7705c48`, and `d7536f5` respectively); the full differential command still + needs a fresh terminal run against the approved independent oracle. +- [x] Exact historical BNG3 checkpoint `5f6da0747beda7d5c4d1728b2aa9caf6f3883dfa` + was rebuilt in detached worktree `/private/tmp/bng3-asan-5f6da07` with the + CI sanitizer flags and `BUILD_PYTHON_BINDINGS=OFF`, `BUILD_CLI=ON`, and + `BUILD_TESTS=ON`. The exact local recipe used AppleClang 21.0.0 arm64, + CMake 4.4.3, and Ninja 1.13.2; `cmake --build build-clang --parallel 4` + completed, and `ASAN_OPTIONS=detect_leaks=0 ctest --test-dir build-clang + --output-on-failure` reports `100% tests passed out of 161`. The resulting + `build-clang/cpp/bng_cpp` artifact has SHA-256 + `891c7f4203af9d9cdddeb8e565489dfec5ed9742990cab15a463579e71828d54`. + The requested Ubuntu/GCC-12 compiler was unavailable on this macOS host, + so this is supplemental historical Clang-ASan evidence only; hosted Ubuntu + ASan, UBSan/leak, Release, and cross-platform gates remain open. +- [x] Fresh local Debug/ASan validation at BNG3 head + `3fc1ea546117fe9844d3bed3bd962fcf781cb6ab` configured the exact CI sanitizer + flags in a separate `/private/tmp/bng3-asan-f1ee` tree: CMake 4.4.3, Ninja, + AppleClang 21.0.0, `BUILD_PYTHON_BINDINGS=OFF`, `BUILD_CLI=ON`, and + `BUILD_TESTS=ON`. `cmake --build /private/tmp/bng3-asan-f1ee --parallel 4` + completed, and + `ASAN_OPTIONS=detect_leaks=0 ctest --test-dir /private/tmp/bng3-asan-f1ee --output-on-failure` + reported `100% tests passed out of 185` in 20.87 seconds. The temporary + `cpp/bng_cpp` artifact has SHA-256 + `753a245ab667454234f55aa07ff7b501b118f05260986e71c02b6fd9f684a299`. + This is supplementary local arm64 evidence only; hosted Ubuntu ASan, + UBSan/leak, Release, and cross-platform gates remain open. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts:3386-3414` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` removes a leading compartment + volume factor before mass-action normalization. The tests-first BNG3 port is + `19a90ed83fad4529bb8e85e1a538e00ade274cb2`: the red-first output was + `r: M_A()@cell -> M_P()@cell __compartment_cell__ * k`, while the repaired + contract is + `tests/python/test_modern_atomizer.py::test_playground_writer_strips_leading_compartment_factor_from_mass_action` + with `r: M_A()@cell -> M_P()@cell k`. The modern Atomizer suite reports `80 + passed`, the full Python gate reports `255 passed, 27 skipped, 9 warnings`, + and exact-tree CTest reports `185/185`. This closes only the bounded + elementary leading-factor slice; nonlinear and broader rate normalization + parity remain open. +- [x] The same Playground source block at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` strips internal multiplicative and + divisive compartment factors before mass-action normalization. The + tests-first BNG3 port is `c5429140e7436770bcb996d17a2fbdd9d0db2de9`; the + red-first outputs were `r: M_A()@cell -> M_P()@cell k * + __compartment_cell__` for multiplication and `r: M_A()@cell -> M_P()@cell + k * _c_A() / __compartment_cell__` for division. The repaired contract is + `tests/python/test_modern_atomizer.py::test_playground_writer_strips_internal_compartment_factor_from_mass_action` + with both rates emitted as `r: M_A()@cell -> M_P()@cell k`. The focused + test command + `PYTHONPATH=build/cpp:python python -m pytest tests/python/test_modern_atomizer.py -q -k internal_compartment_factor` + reports `2 passed`; the modern Atomizer glob reports `82 passed`; the full + Python gate reports `257 passed, 27 skipped, 9 warnings` in `11.28s`; exact + tree `ctest --test-dir build --output-on-failure` reports `185/185`; Ruff + passes; and Black reports `186 files would be left unchanged`. The existing + native smoke artifact `build/cpp/bng_cpp` remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public head readback is `c5429140e7436770bcb996d17a2fbdd9d0db2de9`; + hosted CI run + [33588500334](https://github.com/RuleWorld/BNG3/actions/runs/33588500334), + matrix run + [33588500343](https://github.com/RuleWorld/BNG3/actions/runs/33588500343), + and formatting run + [33588500425](https://github.com/RuleWorld/BNG3/actions/runs/33588500425) + were pending when read back. The exact-head smoke command + `PYTHONPATH=build/cpp:python python -m pytest -c tests/validation/pytest.ini tests/validation -m smoke --bng-cpp build/cpp/bng_cpp -q` + reports `4 passed, 15 skipped, 175 deselected, 1 warning` in `8.24s`; + skips remain explicit missing-reference/legacy-oracle and sandbox + `run_network`/process-inspection gaps. This closes only the bounded + elementary internal-factor slice; remaining source factor placement, + zero-order/nonlinear normalization, and broader writer/parser/SBML parity + remain open. +- [x] Playground `src/lib/atomizer/writer/eventActions.ts:293-294` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` uses JavaScript + `Math.max(1, Math.round(...))` for scheduled event phase steps. The + tests-first BNG3 port is + `6b71ecc613f670667328cd49fa451196e0cebe29` in + `python/bionetgen/atomizer/modern/events.py`: red-first Python banker's + rounding emitted `n_steps=>2` for both half-step phases, while the repaired + source-compatible contract emits `n_steps=>3` for both. The regression is + `tests/python/test_modern_atomizer.py::test_playground_event_actions_use_source_half_up_step_rounding`; + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer.py -q -k half_up_step_rounding` reports + `1 passed, 42 deselected, 1 warning`; the modern Atomizer command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer*.py -q` reports `83 passed, 1 warning`, + and `PYTHONPATH=build/cpp:python python -m pytest tests/python -q` reports + `258 passed, 27 skipped, 9 warnings` in `10.53s`. The exact-tree command + `ctest --test-dir build --output-on-failure` reports `185/185`; `ruff check + --no-cache python/ tests/python/ scripts/` passes; and + `black --check python/ tests/python/ scripts/` reports `186 files would be + left unchanged`. This Python-only checkpoint changes no native artifact; the + existing `build/cpp/bng_cpp` smoke artifact remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public head readback is + `6b71ecc613f670667328cd49fa451196e0cebe29`; hosted CI run + [33589168226](https://github.com/RuleWorld/BNG3/actions/runs/33589168226), + matrix run + [33589168215](https://github.com/RuleWorld/BNG3/actions/runs/33589168215), + and formatting run + [33589168250](https://github.com/RuleWorld/BNG3/actions/runs/33589168250) + were pending when read back. This closes only nonnegative half-tie step + rounding; broader event semantics and Atomizer writer/event parity remain + open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:32-44,73-103` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` reports dependency + cycles as bounded `DEP001` warnings with the traversed path and suppresses + further messages after `ATOMIZER_DEP_CYCLE_LOG_LIMIT`. The tests-first BNG3 + port is `7323ae8685d83343204c38fa3a6bc4b055affc40` in + `python/bionetgen/atomizer/modern/core.py`; its red-first focused test + observed the expected sorted order but `0` warnings, and the repaired + command `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_core.py -q -k dependency_cycles` reports + `1 passed, 3 deselected, 1 warning`. The modern Atomizer suite reports `84 + passed, 1 warning`; the full Python gate reports `259 passed, 27 skipped, 9 + warnings`; exact-tree Release/Ninja CTest reports `185/185`; Ruff passes; + and Black reports `186 files would be left unchanged`. This Python-only + checkpoint leaves the native `build/cpp/bng_cpp` artifact unchanged at + SHA-256 `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `7323ae8685d83343204c38fa3a6bc4b055affc40`; hosted CI run + [33590840330](https://github.com/RuleWorld/BNG3/actions/runs/33590840330), + CodeQL run + [33590840321](https://github.com/RuleWorld/BNG3/actions/runs/33590840321), + and formatting run + [33590840350](https://github.com/RuleWorld/BNG3/actions/runs/33590840350) + were queued when read back. This closes only dependency-cycle diagnostics; + the remaining modern core, parser, writer, SBML, and independent parity + gaps remain open. +- [x] Playground `src/lib/atomizer/parser/sbmlParser.ts:2703-2721` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` exposes + `getAnnotationsByQualifier`, filtering raw annotation resources by the + biological/model qualifier kind and numeric qualifier. The tests-first BNG3 + port is `c00c9bace01dbbc7c0e5fa4969cfaed9e35f2879` in + `python/bionetgen/atomizer/modern/annotation.py` and the modern package + facade; its red-first focused test failed at import because the public name + was absent, and the repaired command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_annotations.py -q -k qualifier_helper` + reports `1 passed, 6 deselected, 1 warning`. The modern Atomizer suite + reports `85 passed, 1 warning`; the full Python gate reports `260 passed, 27 + skipped, 9 warnings`; exact-tree Release/Ninja CTest reports `185/185`; + Ruff passes; and Black reports `186 files would be left unchanged`. This + Python-only checkpoint leaves the native `build/cpp/bng_cpp` artifact + unchanged at SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `c00c9bace01dbbc7c0e5fa4969cfaed9e35f2879`; hosted CI run + [33591261066](https://github.com/RuleWorld/BNG3/actions/runs/33591261066), + CodeQL run + [33591261064](https://github.com/RuleWorld/BNG3/actions/runs/33591261064), + and formatting run + [33591261083](https://github.com/RuleWorld/BNG3/actions/runs/33591261083) + were queued when read back. This closes only the qualifier-resource helper + facade; the remaining parser, annotation, writer, SBML, and independent + parity gaps remain open. +- [x] Playground `src/lib/atomizer/parser/sbmlParser.ts:2721-2750` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` exposes + `extractUniProtIds` and `extractGOTerms`, matching identifier substrings, + preserving source order, and normalizing GO matches to `GO:`. The + tests-first BNG3 port is `50da22fcba58782fa4d258294262ba89cf137198` in + `python/bionetgen/atomizer/modern/parser.py` and the modern facade; its + red-first focused test failed because both camelCase names were absent (and + the existing GO extractor returned no match), while the repaired command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_annotations.py -q -k annotation_id_helpers` + reports `1 passed, 7 deselected, 1 warning`. The modern Atomizer suite + reports `86 passed, 1 warning`; the full Python gate reports `261 passed, 27 + skipped, 9 warnings`; exact-tree Release/Ninja CTest reports `185/185`; + Ruff passes; and Black reports `186 files would be left unchanged`. This + Python-only checkpoint leaves the native `build/cpp/bng_cpp` artifact + unchanged at SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `50da22fcba58782fa4d258294262ba89cf137198`; hosted CI run + [33591493598](https://github.com/RuleWorld/BNG3/actions/runs/33591493598), + CodeQL run + [33591493608](https://github.com/RuleWorld/BNG3/actions/runs/33591493608), + and formatting run + [33591493576](https://github.com/RuleWorld/BNG3/actions/runs/33591493576) + were queued when read back. This closes only the parser identifier-helper + facade; the remaining parser, annotation, writer, SBML, and independent + parity gaps remain open. +- [x] Playground `src/lib/atomizer/utils/helpers.ts:172-190` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` keeps memoization caches in + process-global storage keyed by the explicit `cacheKey`, so separately + wrapped functions share a named cache. BNG3 matches that explicit-key + contract at `eecbdce646d06a9bd4c117379ee191b0dc6a1cc5` in + `python/bionetgen/atomizer/modern/helpers.py`. The tests-first contract is + `tests/python/test_modern_atomizer_helpers.py::test_playground_pmemoize_shares_explicit_cache_keys`. + The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer_helpers.py -q -k pmemoize_shares_explicit_cache_keys` + reported `1 failed, 4 deselected in 0.24s`; the repaired focused command + reports `1 passed, 4 deselected in 0.24s`. The complete helper test file + reports `5 passed in 0.24s`; the modern Atomizer glob reports `143 passed in + 0.46s`; the full Python gate reports `318 passed, 27 skipped, 8 warnings` + in `11.21s`; exact Release/Ninja CTest reports `190/190` in `1.32s`. + Changed-file Ruff (`--no-cache`) passes, Black with the configured + `--target-version py312` reports `2 files would be left unchanged`, and + `git diff --check` passes. The native `build/cpp/bng_cpp` artifact remains + SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `eecbdce646d06a9bd4c117379ee191b0dc6a1cc5`; hosted CI + [33717664851](https://github.com/RuleWorld/BNG3/actions/runs/33717664851), + CodeQL [33717664913](https://github.com/RuleWorld/BNG3/actions/runs/33717664913), + and formatting + [33717664952](https://github.com/RuleWorld/BNG3/actions/runs/33717664952) + remain queued and are not completion evidence. This closes only explicit + named-cache sharing; BNG3's default function key uses Python's module and + qualified-name representation rather than JavaScript `fn.toString()`, and + argument serialization remains Python-specific, so broader helper parity + remains open. +- [x] Playground `src/lib/atomizer/config/types.ts:93-152,329-393` and + `src/lib/atomizer/index.ts:130-150,390-397` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` expose the complete default + `NamingConventions` data and accept a custom `namingConventions` option in + the Atomizer facade. BNG3 matches the configuration fields, camel-case + compatibility views, custom pattern resolution, default option, and facade + wiring at `094f7ac62a2baae0abebfcac134f558a324a6744`. The tests-first + contracts are + `tests/python/test_modern_atomizer_core.py::test_playground_naming_conventions_export_and_custom_patterns_contract` + and + `tests/python/test_modern_atomizer.py::test_playground_atomizer_uses_source_naming_conventions_option`. + The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer_core.py tests/python/test_modern_atomizer.py -q -k 'naming_conventions_export_and_custom_patterns_contract or uses_source_naming_conventions_option'` + reported `2 failed, 94 deselected in 0.60s`; the repaired focused command + reports `2 passed, 94 deselected in 0.32s`. The modern Atomizer glob reports + `145 passed in 0.42s`; the full Python gate reports `320 passed, 27 skipped, + 8 warnings` in `10.28s`; exact Release/Ninja CTest reports `190/190` in + `1.25s`. Changed-file Ruff passes, Black with the configured + `--target-version py312` reports `5 files would be left unchanged`, and + `git diff --check` passes. The native `build/cpp/bng_cpp` artifact remains + SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `094f7ac62a2baae0abebfcac134f558a324a6744`; hosted CI + [33767541435](https://github.com/RuleWorld/BNG3/actions/runs/33767541435), + CodeQL [33767541537](https://github.com/RuleWorld/BNG3/actions/runs/33767541537), + and formatting + [33767541467](https://github.com/RuleWorld/BNG3/actions/runs/33767541467) + remain queued and are not completion evidence. This closes the default and + custom naming-configuration slice only; user-structure equivalences, + broader Atomizer core/parser/writer/SBML behavior, and independent parity + remain open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:227-265` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` emits an `INFO`-level `NAM001` + summary after naming-convention analysis, reporting similar-pair and + classification counts. The tests-first BNG3 port is + `71e25111f30eac8d33bdeca6faf1f78949f158f1` in + `python/bionetgen/atomizer/modern/core.py`; its red-first focused test + observed `0` info messages, and the repaired command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_core.py -q -k naming_analysis_reports_summary` + reports `1 passed, 4 deselected`. The modern Atomizer suite reports `87 + passed`; the full Python gate reports `262 passed, 27 skipped, 8 warnings`; + exact-tree Release/Ninja CTest reports `185/185`; and changed-file Ruff and + Black checks pass (`2 files would be left unchanged` for Black). This + Python-only checkpoint leaves the native `build/cpp/bng_cpp` artifact + unchanged at SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `71e25111f30eac8d33bdeca6faf1f78949f158f1`; hosted CI run + [33591884302](https://github.com/RuleWorld/BNG3/actions/runs/33591884302), + CodeQL run + [33591884261](https://github.com/RuleWorld/BNG3/actions/runs/33591884261), + and formatting run + [33591884259](https://github.com/RuleWorld/BNG3/actions/runs/33591884259) + were queued when read back. This closes only the naming-diagnostic facade; + broader Atomizer core/parser/writer/SBML behavior and independent parity + remain open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:536-616` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` emits an `INFO`-level `RXN001` + summary after reaction analysis, reporting binding and modification counts. + The tests-first BNG3 port is + `277e0b66a3bb911ed2faf1f069975cdb2108c592` in + `python/bionetgen/atomizer/modern/core.py`; its red-first focused test + observed `0` info messages while preserving the expected binding map, and + the repaired command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_core.py -q -k reaction_analysis_reports_summary` + reports `1 passed, 5 deselected`. The modern Atomizer suite reports `88 + passed`; the full Python gate reports `263 passed, 27 skipped, 8 warnings`; + exact-tree Release/Ninja CTest reports `185/185`; and changed-file Ruff and + Black checks pass (`2 files would be left unchanged` for Black). This + Python-only checkpoint leaves the native `build/cpp/bng_cpp` artifact + unchanged at SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `277e0b66a3bb911ed2faf1f069975cdb2108c592`; hosted CI run + [33592147684](https://github.com/RuleWorld/BNG3/actions/runs/33592147684), + CodeQL run + [33592147712](https://github.com/RuleWorld/BNG3/actions/runs/33592147712), + and formatting run + [33592147725](https://github.com/RuleWorld/BNG3/actions/runs/33592147725) + were queued when read back. This closes only the reaction-diagnostic + facade; broader Atomizer core/parser/writer/SBML behavior and independent + parity remain open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:924-1126` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` emits an `INFO`-level `SCT001` + summary after building the species-composition table, reporting total, + elemental, and complex counts. The tests-first BNG3 port is + `90c36849e9d24feb4ad1d720cad218aef184ed22` in + `python/bionetgen/atomizer/modern/core.py`; its red-first focused test + built the expected one-entry table but observed no `SCT001` message, and + the repaired command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_core.py -q -k sct_builder_reports_summary` + reports `1 passed, 6 deselected`. The modern Atomizer suite reports `89 + passed`; the full Python gate reports `264 passed, 27 skipped, 8 warnings`; + exact-tree Release/Ninja CTest reports `185/185`; and changed-file Ruff and + Black checks pass (`2 files would be left unchanged` for Black). This + Python-only checkpoint leaves the native `build/cpp/bng_cpp` artifact + unchanged at SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `90c36849e9d24feb4ad1d720cad218aef184ed22`; hosted CI run + [33592851760](https://github.com/RuleWorld/BNG3/actions/runs/33592851760), + CodeQL run + [33592851732](https://github.com/RuleWorld/BNG3/actions/runs/33592851732), + and formatting run + [33592851748](https://github.com/RuleWorld/BNG3/actions/runs/33592851748) + were queued when read back. This closes only the SCT-diagnostic facade; + broader Atomizer core/parser/writer/SBML behavior and independent parity + remain open. +- [x] Playground `src/lib/atomizer/index.ts:56-58,80-84,104-194,453-458` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` configures the shared + logger, emits the successful conversion lifecycle diagnostics `ATM003` through + `ATM009` (including exact model and artifact counts), emits `ATM010` on + conversion failure, and clears logger state from `clear()`. The tests-first + BNG3 port is `16fee366755b73a4691d6c854e901471f8399b1c` in + `python/bionetgen/atomizer/modern/__init__.py` and + `tests/python/test_modern_atomizer.py`. The red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer.py -q -k lifecycle_diagnostics` reported + `1 failed, 43 deselected` because the expected ATM messages were absent; the + repaired command reports `1 passed, 43 deselected`. The modern Atomizer glob + reports `90 passed`; the full Python gate reports `265 passed, 27 skipped, 9 + warnings`; Ruff passes; Black reports the two changed files unchanged; and + exact-tree `ctest --test-dir build --output-on-failure` reports `185/185`. + The native `build/cpp/bng_cpp` artifact is unchanged at SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `16fee366755b73a4691d6c854e901471f8399b1c`; hosted CI run + [33593502778](https://github.com/RuleWorld/BNG3/actions/runs/33593502778), + CodeQL run + [33593502821](https://github.com/RuleWorld/BNG3/actions/runs/33593502821), + and formatting run + [33593502779](https://github.com/RuleWorld/BNG3/actions/runs/33593502779) + were queued when read back. This closes only the facade's logger side + effects: `AtomizerResult.log` remains the existing `List[str]` compatibility + field rather than source `LogMessage[]`, and BNG3's synchronous parser has no + source `initialize()`/`ATM001`-`ATM002` lifecycle; broader Atomizer + parser/writer/SBML and independent parity gaps remain open. +- [x] Playground `src/lib/atomizer/parser/sbmlParser.ts:1529-1534` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` emits the `SBM004` + parsed-model summary before returning the structured model. The tests-first + BNG3 port is `6b4e96182ab9032bc61ee58c42afbda3bfed9bfb` in + `python/bionetgen/atomizer/modern/parser.py`; the red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer.py -q -k parser_reports_model_summary` + reported `1 failed, 44 deselected, 2 warnings` because no summary was + logged, and the repaired command reported `1 passed, 44 deselected, 1 + warning`. The following modern Atomizer suite reports `91 passed`; the full + Python gate reports `266 passed, 27 skipped, 9 warnings`; Ruff and Black + pass; and exact-tree `ctest --test-dir build --output-on-failure` reports + `185/185`. The native `build/cpp/bng_cpp` artifact remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public checkpoint readback is `6b4e96182ab9032bc61ee58c42afbda3bfed9bfb`; + CI [33593884993](https://github.com/RuleWorld/BNG3/actions/runs/33593884993), + CodeQL [33593884974](https://github.com/RuleWorld/BNG3/actions/runs/33593884974), + and formatting [33593884986](https://github.com/RuleWorld/BNG3/actions/runs/33593884986) + were queued. This closes only the parser summary diagnostic; parser + extraction parity and independent SBML/BNGL parity remain open. +- [x] Playground `src/lib/atomizer/parser/sbmlParser.ts:1531-1534` at the + same pinned reference maps import-warning severities to `SBM020` dropped, + `SBM021` approximated, and `SBM022` informational diagnostics, preserving + category brackets and repeated-warning counts. The tests-first BNG3 port is + `6476e4271edb652f5dee8bbe03f2df9fdd5543dd`; its red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer.py -q -k parser_emits_import_warning_codes_and_counts` + reported `1 failed, 45 deselected, 2 warnings`, and the repaired command + reported `1 passed, 45 deselected, 1 warning`. The modern suite then reports + `92 passed`; the full Python gate reports `267 passed, 27 skipped, 9 + warnings`; Ruff and Black pass; and CTest remains `185/185`. Exact public + head readback is `6476e4271edb652f5dee8bbe03f2df9fdd5543dd`; CI + [33594023815](https://github.com/RuleWorld/BNG3/actions/runs/33594023815), + CodeQL [33594023785](https://github.com/RuleWorld/BNG3/actions/runs/33594023785), + and formatting [33594023819](https://github.com/RuleWorld/BNG3/actions/runs/33594023819) + were queued. This closes only diagnostic severity/count mapping; parser + semantics and independent oracle coverage remain open. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts:58-76,3365-3369` + at the pinned reference reports `BNW011` when an eligible reaction has no + kinetic law, applies the documented fallback rate, and bounds repeated + logs. The tests-first BNG3 port is + `78430997d4f828bd8c92e7f9806c0553bbbc43a2` in + `python/bionetgen/atomizer/modern/writer.py`; the red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer.py -q -k reports_missing_kinetic_laws` + reported `1 failed, 46 deselected, 2 warnings`, and the repaired command + reported `1 passed, 46 deselected, 1 warning`. The modern suite reports `93 + passed`; the full Python gate reports `268 passed, 27 skipped, 9 warnings`; + Ruff and Black pass; CTest reports `185/185`; and the native artifact remains + SHA-256 `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public head readback is `78430997d4f828bd8c92e7f9806c0553bbbc43a2`; + CI [33594217262](https://github.com/RuleWorld/BNG3/actions/runs/33594217262), + CodeQL [33594217315](https://github.com/RuleWorld/BNG3/actions/runs/33594217315), + and formatting [33594217279](https://github.com/RuleWorld/BNG3/actions/runs/33594217279) + were queued. This closes only missing-kinetic-law observability and does not + establish a scientifically validated fallback-rate policy for all models. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts:2280-2284` at the + pinned reference emits `BNW012` when non-species rate-rule targets are + materialized as synthetic state species. The tests-first BNG3 port is + `7166ef8b45c1a1ffefe6a3e8528f8bddd059daab`; the red-first focused command + reported `1 failed, 46 deselected, 2 warnings` with the synthetic species + present but no diagnostic, and the repaired command reported `1 passed, 46 + deselected, 1 warning`. The modern suite reports `93 passed`; the full + Python gate reports `268 passed, 27 skipped, 9 warnings`; Ruff and Black + pass; CTest reports `185/185`; and exact public head readback is + `7166ef8b45c1a1ffefe6a3e8528f8bddd059daab`. Hosted CI + [33594335170](https://github.com/RuleWorld/BNG3/actions/runs/33594335170), + CodeQL [33594335190](https://github.com/RuleWorld/BNG3/actions/runs/33594335190), + and formatting [33594335172](https://github.com/RuleWorld/BNG3/actions/runs/33594335172) + were queued. This closes only the synthetic-rate-rule diagnostic; the + full rate-rule execution and independent parity gates remain open. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts:60-67,1639-1649, + 1737-1740,3774-3777` at the pinned reference reports `BNW004` for + non-adjacent-compartment transport, bounded by the source log limit, while + adjacent transport remains quiet. The tests-first BNG3 port is + `a19d1ccdea7b698709c708d8716998fedfd20f7a`; the red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer.py -q -k reports_nonadjacent_transport_reactions` + reported `1 failed, 47 deselected, 2 warnings`, and the repaired command + reported `1 passed, 47 deselected, 1 warning`. The modern suite reports `94 + passed`; the full Python gate reports `269 passed, 27 skipped, 9 warnings`; + Ruff and Black pass; CTest reports `185/185`; and the native artifact remains + SHA-256 `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public head readback is `a19d1ccdea7b698709c708d8716998fedfd20f7a`; + CI [33594536507](https://github.com/RuleWorld/BNG3/actions/runs/33594536507), + CodeQL [33594536494](https://github.com/RuleWorld/BNG3/actions/runs/33594536494), + and formatting [33594536626](https://github.com/RuleWorld/BNG3/actions/runs/33594536626) + were queued. This closes only non-adjacent transport observability; transport + dynamics, all writer parity, and independent SBML/BNGL parity remain open. +- [x] Playground `src/lib/atomizer/index.ts:105-124,208-274` at the pinned + reference enables the flat-only large-model fast path when + `ATOMIZER_LARGE_FASTPATH` is enabled and any of the source thresholds + (`1500` species, `800` reactions, or `5000000` SBML characters) is reached; + it emits `ATM011` and creates one elemental molecule/seed per SBML species, + deliberately bypassing structure inference. The tests-first BNG3 port is + `a288a235fd7d2c1a317d2db8fe2e3e01f695cb70` in + `python/bionetgen/atomizer/modern/__init__.py` and + `tests/python/test_modern_atomizer.py`. The threshold-forced red-first + command `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer.py -k large_flat_fast_path -q` reported + `1 failed, 48 deselected` because the normal SCT path emitted `ATM005` + instead of `ATM011`; the repaired command reported `1 passed, 48 + deselected`. The complete modern Atomizer gate reports `95 passed`; the full + Python gate reports `270 passed, 27 skipped, 8 warnings`; Ruff and Black + pass; exact-tree CTest reports `185/185`; and the native + `build/cpp/bng_cpp` artifact remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `a288a235fd7d2c1a317d2db8fe2e3e01f695cb70`; hosted CI + [33595341449](https://github.com/RuleWorld/BNG3/actions/runs/33595341449), + CodeQL [33595341429](https://github.com/RuleWorld/BNG3/actions/runs/33595341429), + and formatting [33595341400](https://github.com/RuleWorld/BNG3/actions/runs/33595341400) + were queued, while `gh pr checks 2` still reports pending jobs. This closes + only the source-shaped flat fast path. It deliberately does not claim large- + model speedup, structure-inference parity, annotation parity, or release + qualification; the broad Atomizer, API, SBML, Multi, NFsim, provenance, + packaging, and hosted terminal gates remain open. +- [x] Playground `src/lib/atomizer/parser/bngXmlParser.ts:294-315` at the + pinned reference emits `BNGXML002` when the BNG-SBML fallback rescales an + MM/Sat constant for a compartment and emits `BNGXML001` after conversion. + The tests-first BNG3 port is + `d288387395d9537be7bd93933c79dbb112d6a9fc` in + `python/bionetgen/atomizer/modern/bng_xml.py` and + `tests/python/test_modern_atomizer_annotations.py`. The red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_annotations.py -k reports_source_diagnostics -q` + reported `1 failed, 8 deselected, 1 warning` because both source diagnostics + were absent; the repaired command reported `1 passed, 8 deselected`. The + complete modern Atomizer gate reports `96 passed`; the full Python gate + reports `271 passed, 27 skipped, 8 warnings`; Ruff and Black pass; exact-tree + CTest reports `185/185`; and `build/cpp/bng_cpp` remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `d288387395d9537be7bd93933c79dbb112d6a9fc`; hosted CI + [33595883009](https://github.com/RuleWorld/BNG3/actions/runs/33595883009), + CodeQL [33595882979](https://github.com/RuleWorld/BNG3/actions/runs/33595882979), + and formatting [33595882989](https://github.com/RuleWorld/BNG3/actions/runs/33595882989) + were queued. This closes only fallback-converter observability; BNG-XML + semantic round trips, schema validation, and independent format parity remain + open. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts:3321-3647` and + `3683-3864` at the pinned reference preserve a reversible SBML net rate as + one irreversible functional rule when reactant neutralization would corrupt + a saturation-like denominator (`split_rxn` fallback). The tests-first BNG3 + port is `29b7687a1c49617715cacba1e0985e7b8568d5b9` in + `python/bionetgen/atomizer/modern/writer.py`, with the source-derived + regression `tests/python/test_modern_atomizer.py::test_playground_writer_falls_back_for_unsplittable_reversible_nonlinear_rate`. + The red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer.py -k + unsplittable_reversible_nonlinear_rate -q` reported `1 failed, 49 + deselected, 2 warnings` because BNG3 emitted `<->` and stripped the + denominator-sensitive direction; the repaired command reported `1 passed, + 49 deselected, 1 warning` and retained the full net expression on `->`. The + complete modern Atomizer gate reports `97 passed`; the full Python gate + reports `272 passed, 27 skipped, 8 warnings`; Ruff and Black pass; exact-tree + CTest reports `185/185`; provenance and corpus-manifest checks pass with the + baseline still pending maintainer approval; the exception ledger reports + `0 active`; and validation smoke reports `4 passed, 15 skipped`. The native + `build/cpp/bng_cpp` artifact remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `29b7687a1c49617715cacba1e0985e7b8568d5b9`; hosted CI + [33596704800](https://github.com/RuleWorld/BNG3/actions/runs/33596704800), + CodeQL [33596704673](https://github.com/RuleWorld/BNG3/actions/runs/33596704673), + and formatting [33596704771](https://github.com/RuleWorld/BNG3/actions/runs/33596704771) + were queued. This closes only the source-shaped reversible-rate fallback; + broader writer/parser/SBML and independent parity remain open. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts:1316-1385` at the + pinned reference inlines calls to proven constant zero-argument functions so + BNG2's function-block reordering cannot leave a dynamic function dependent on + a later constant definition. The tests-first BNG3 port is + `e847eaa89aae8bc6421be78f93fa261709b2cb9d` in + `python/bionetgen/atomizer/modern/writer.py`; the source-derived regression + is `tests/python/test_modern_atomizer_writer_parameters.py::test_write_functions_inlines_constant_calls_into_dynamic_bodies`. + The red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_writer_parameters.py -k constant_calls -q` + reported `1 failed, 5 deselected, 2 warnings`; the repaired command reported + `1 passed, 5 deselected, 1 warning`. The focused writer/helper gate reports + `14 passed`; the complete modern Atomizer gate reports `98 passed`; the full + Python gate reports `273 passed, 27 skipped, 9 warnings`; Ruff and Black pass; + exact-tree CTest reports `185/185`; provenance and corpus-manifest checks pass + with the baseline still pending maintainer approval; the exception ledger + reports `0 active`; and validation smoke reports `4 passed, 15 skipped, 175 + deselected`. The native `build/cpp/bng_cpp` artifact remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `e847eaa89aae8bc6421be78f93fa261709b2cb9d`; hosted CI + [33597684992](https://github.com/RuleWorld/BNG3/actions/runs/33597684992), + CodeQL [33597685061](https://github.com/RuleWorld/BNG3/actions/runs/33597685061), + and formatting [33597685018](https://github.com/RuleWorld/BNG3/actions/runs/33597685018) + are queued. This closes only the source-shaped BNG2 function-ordering + workaround; broad writer/parser/SBML parity and independent validation + remain open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:348-371` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` recognizes saturation-like + reaction rates only for explicit `Sat`/`MM`/`Hill` terms or the source's + quotient-plus-two-products shape. The tests-first BNG3 repair is + `6f7573f40ff04cfaed517bbcd11c606eeca7cb87` in + `python/bionetgen/atomizer/modern/core.py`, with the source-derived + regression `tests/python/test_modern_atomizer_core.py::test_reaction_classification_requires_reference_saturation_shape`. + The red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_core.py -k reference_saturation_shape -q` + reported `1 failed, 7 deselected`; the repaired command reported `1 passed, + 7 deselected`. The focused core gate reports `8 passed`; the complete modern + Atomizer gate reports `99 passed`; the full Python gate reports `274 passed, + 27 skipped, 9 warnings`; Ruff and Black pass; the native + `build/cpp/bng_cpp` artifact remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `6f7573f40ff04cfaed517bbcd11c606eeca7cb87`; hosted CI + [33629097334](https://github.com/RuleWorld/BNG3/actions/runs/33629097334), + CodeQL [33629097260](https://github.com/RuleWorld/BNG3/actions/runs/33629097260), + and formatting + [33629097206](https://github.com/RuleWorld/BNG3/actions/runs/33629097206) + are queued. This closes only the source-shaped saturation classifier + guard; broader Atomizer core/writer/parser/SBML, direct-NFsim, and + independent parity remain open. +- [x] Playground `src/lib/atomizer/utils/helpers.ts:543-545` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` emits the source's fixed + `2.302585093` denominator for `log10(x)` conversion. The tests-first BNG3 + repair is `5ad75244662278f18485864352fd104698115a74` in + `python/bionetgen/atomizer/modern/helpers.py`, with the source-derived + contract in `tests/python/test_modern_atomizer_helpers.py::test_playground_helpers_string_and_math_contract`. + The red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_helpers.py -k string_and_math -q` + reported `1 failed, 3 deselected`; the repaired command reported `1 passed, + 3 deselected`. The focused helper gate reports `4 passed`; the complete + modern Atomizer gate reports `99 + passed`; the full Python gate reports `274 passed, 27 skipped, 9 warnings`; + Ruff and Black pass. Exact public PR/ref head readback is + `5ad75244662278f18485864352fd104698115a74`; hosted CI + [33629555826](https://github.com/RuleWorld/BNG3/actions/runs/33629555826), + CodeQL [33629555887](https://github.com/RuleWorld/BNG3/actions/runs/33629555887), + and formatting + [33629555830](https://github.com/RuleWorld/BNG3/actions/runs/33629555830) + are queued. This closes only the source-shaped `log10` spelling; broader + expression, Atomizer, SBML, and independent parity remain open. +- [x] Playground `src/lib/atomizer/core/structures.ts:752-765` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` deletes bonds from the selected + molecule's component whose name matches the other supplied pair member, + case-insensitively. The tests-first BNG3 repair is + `167d6b8ab4ca6f8c709a5ab6cb894317b2f24272` in + `python/bionetgen/atomizer/modern/structures.py`, with the source-derived + regression `tests/python/test_modern_atomizer_structures.py::test_delete_bond_matches_playground_component_pair_semantics`. + The red-first command + `PYTHONPATH=build/cpp:python python -m pytest + tests/python/test_modern_atomizer_structures.py -k delete_bond -q` reported + `1 failed, 4 deselected, 2 warnings`; the repaired command reported `5 + passed` for the focused structures file. The complete modern Atomizer gate + reports `100 passed`; the full Python gate reports `275 passed, 27 skipped, + 9 warnings`; Ruff and Black pass. The native `build/cpp/bng_cpp` artifact + remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `167d6b8ab4ca6f8c709a5ab6cb894317b2f24272`; hosted CI + [33630013174](https://github.com/RuleWorld/BNG3/actions/runs/33630013174), + CodeQL [33630013218](https://github.com/RuleWorld/BNG3/actions/runs/33630013218), + and formatting + [33630013175](https://github.com/RuleWorld/BNG3/actions/runs/33630013175) + are queued. This closes only the source-shaped component-pair helper; + broader structure, parser/writer/SBML, direct-NFsim, and independent parity + remain open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:1338-1348` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` uses nullish presence semantics + for `initialAmountSet` and `initialConcentrationSet`: an explicit `false` + suppresses the corresponding value, while an unavailable flag falls back to + a non-zero parsed value. The tests-first BNG3 port is + `27ae8d63e5cef1a1af005b2e567a6ad5447e5f28` in + `python/bionetgen/atomizer/modern/types.py` and + `python/bionetgen/atomizer/modern/core.py`, with the source-derived + regression `tests/python/test_modern_atomizer.py::test_playground_seed_selection_honors_explicit_unset_initial_values`. + The red-first command + `env PYTHONPATH=python pytest -q tests/python/test_modern_atomizer.py -k explicit_unset_initial_values` + reported `1 failed, 50 deselected` after checkout import resolution; the + repaired command reported `1 passed, 50 deselected`. The complete modern + Atomizer gate reports `83 passed, 18 skipped`; the full Python gate reports + `276 passed, 27 skipped, 9 warnings` with `PYTHONPATH=python:build/cpp`; + root Release/Ninja CTest reports `185/185`; Ruff and Black pass; and + `build/cpp/bng_cpp` remains SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Exact public PR/ref head readback is + `27ae8d63e5cef1a1af005b2e567a6ad5447e5f28`; hosted CI + [33631116459](https://github.com/RuleWorld/BNG3/actions/runs/33631116459), + CodeQL [33631116350](https://github.com/RuleWorld/BNG3/actions/runs/33631116350), + and formatting + [33631116335](https://github.com/RuleWorld/BNG3/actions/runs/33631116335) + remain queued. The no-extension full-Python invocation is not a valid + completion gate because it reports the known missing native backend; this + closes only seed-value presence semantics, while broader parser/writer/SBML, + direct-NFsim, provenance, and release parity remain open. +- [x] Fresh external-Perl Tier-P NET parity at semantic head `88f4e54` used + `BNG2_PERL=/private/tmp/bng2-oracle.TToh58/source/bng2/BNG2.pl` from source + revision `fde0cd6a522c9f988d5495db31c70ce0f98e744b`. The exact command + selected 100 tests and completed `54 passed, 46 skipped, 94 deselected` in + `125.70s`, with no assertion failures. The 46 skips are still honest + oracle/asset/environment gaps (including legacy syntax, missing NFsim, + missing test data, and the sandbox's blocked `ps`), so this is an improved + external subset result, not complete Tier-P qualification. +- [x] Fresh selected Tier-NF native-oracle evidence at semantic head `88f4e54` + used the independent binary `/Users/akutuva/Documents/BioNetGen/nfsim/build/NFsim` + from source checkout `a6f9fa945c9d6e1e122e789c952260112c93f157`, SHA-256 + `7302fe29b16d1ebe86369f752f2a49d2c87ef16539faaec11b82294a9fa56d22`. + The full selected command completed `10 passed, 184 deselected, 3 warnings` + in `175.92s`; direct/XML shadow-only selection separately completed + `4 passed, 190 deselected, 3 warnings` in `4.87s`. This qualifies the + selected four-model subset only; full Tier-NF corpus and three-way evidence + remain open. +- [x] An independent accepted-cutoff NFsim oracle was built from source + revision `3b046fc1b9f76719d92be22279b24992cdae7c35` in the isolated + worktree `/private/tmp/bng3-nfsim-3b046` with Release/Ninja, executable-only, + and LTO-disabled settings. The exact configure/build recipe was + `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + -DNFSIM_BUILD_EXECUTABLE=ON -DNFSIM_BUILD_LIBRARY=OFF + -DNFSIM_ENABLE_LTO=OFF` followed by `cmake --build build --parallel 4`; + the build completed at `[92/92] Linking CXX executable NFsim` with only + pre-existing legacy muParser/C++11 warnings. Using that binary as + `NFSIM_BIN` against the BNG3 semantic checkpoint `7977966` (the current + public `b82e08c` adds documentation only), the exact selected NF command + completed `10 passed, 184 deselected, 3 warnings` in `138.03s`. This is + independent accepted-cutoff evidence for the selected validation slice; + it does not close the full Tier-NF corpus, distributional gate, broader + direct-NFsim parity, or energy/provenance qualification. +- [x] Fresh selected Tier-NF independent evidence at semantic head + `871d261442be2f4808cbcabed51f6a017ba5488a` used the accepted-cutoff source + revision `3b046fc1b9f76719d92be22279b24992cdae7c35` and binary + `/private/tmp/bng3-nfsim-3b046/build/NFsim` (SHA-256 + `c30a80b6ff9cf1fae04bc9f45556c4d5fa9c3b00d053fb6abade80436ee46394`). The + exact command + `env NFSIM_BIN=/private/tmp/bng3-nfsim-3b046/build/NFsim BNG_CPP=/Users/akutuva/Documents/BioNetGen/BNG3/build/cpp/bng_cpp PYTHONPATH=python:build/cpp python -m pytest tests/validation -m nf -q` + completed `10 passed, 184 deselected, 3 warnings` in `135.81s`. It covers + the selected `localfunc`, `motor`, `simple_system`, and `tlbr` native + ensembles, direct/XML shadow checks, and fixed-seed endpoint checks. The + three warnings are the known zero-denominator invalid-divide diagnostic at + `tests/validation/compare.py:1278` for the affected localfunc/motor/ + simple_system comparisons. This qualifies the selected slice only; full + Tier-NF, broader direct-NFsim three-way parity, and energy/provenance gates + remain open. +- [x] The full NFsim AST adapter executable passes 118 test cases and 1280 + assertions on `2c498af`. It covers compact energy evaluation, cached compact + rate factors, specialized reverse propensities, sparse selector ordering, + cached single- and multi-term Arrhenius factors, direct-product endpoint + identity propagation, safe direct-product traversal, cached pre-fire binding + rejection, compact partner mapping-slot compaction, indexed cross-type + partner refresh, shared partner-pool updates, dense and sparse type-invariant + membership decisions, deferred weighted-side propensity capture, + endpoint-refined membership refresh decisions, compact sorted and inline + reaction-membership IDs, connected t3 trajectory + parity, materialized fallback, + all-forward compact partner-pool refresh early return, + pure-context homodimer/trimer/scaffold counting, + transformed homodimer binding multiplicity, and pure DOR context counting. + It also covers the source-derived functional symmetry/TotalRate correction, + repeated connectivity direct-endpoint scratch refresh with lazy connectivity + product lookup allocation, and one-way direct + Arrhenius binding/state-change expansion including the compact forward-only + runtime path, the source-derived bulk molecule-pool reuse regression, and + source-derived multi-bond product-molecularity checks on direct and XML + paths, including a negative single-bond ring control. It also covers the + source-derived NFsim `t3.xml` LocalFunction XML contract: a plain scoped + local-function reaction rate is serialized through a generated composite + wrapper, while a nested CompositeFunction remains direct (5 assertions). + It also covers the + source-derived `reactant_1()` compatibility placeholder and dynamic + reactant-count rate on direct and in-memory XML paths (11 assertions), plus + XML preservation of the `TotalRate` modifier (9 assertions), and the IfTest + conditional global functions with legacy `&&` expressions and live-threshold + trajectory branch semantics on direct and XML paths (74 assertions). It also + covers BNG2 inferred integer-state handling for wildcard/PLUS/MINUS tokens, + lexical molecule-type registration, canonical inferred component order, and + direct/XML/BNGL writer order. It also covers the source-derived NFsim Issue86 + species-observable dependency refresh: after one `A()` degradation, both + `Species` and `Molecules` observables and their dependent propensities update + from 100 to 99. It also covers the source-derived NFsim Issue78 absolute + clock/equilibrate contract: a nonzero current time is preserved across + equilibration and a time-backed rate observes that absolute origin. +- [x] The accepted NFsim EnergyFunction unit contract from source commit + `f63d676` is covered at `9378000` by 22 assertions: pattern storage, + binding expansion, state-change expansion, forward/reverse names and + rates, and the expected ΔG values. This is a local source-derived unit + gate, not independent native-NFsim parity. +- [ ] The source-derived historical NFsim `test/testSuite/t4.bngl` and related + `t5.bngl` syntax remain open capability gaps. At semantic parent `7186b65`, + BNG3's parser + rejects the t4 fixture with the stable diagnostic `Cannot build model from + source with syntax errors`. Independent inspection found the fixtures were + introduced by NFsim commit `3c7b6a3` as preliminary tests, current BNG2 + rejects `sum(m)`, and the current NFsim `StateCounter` implementation is + retained debug/dead support rather than an active parser/XML contract. + Do not mark this row complete without a maintainer disposition, active + independent oracle, canonical-AST design, runtime semantics, and direct/XML + contract tests. +- [x] The full NFsim tree/system executable passes 157 assertions in 8 test + cases on `7186b65`. This includes the source-derived unsafe output-name + rejection port from NFsim `3527edb` and continuous-vs-chunked `stepTo` + checkpoint tests from NFsim `e3ef4a0` (50 assertions across the two new + cases, including the zero-propensity boundary). +- [x] Full Python/API tests pass on the latest Python-affecting checkpoint + `9c60ca4`: `235 passed, 27 skipped, 8 warnings` from + `PYTHONPATH=python:build/cpp python -m pytest tests/python -q`. The later + `73757ea`, `2d69b99`, `d502e47`, and `4ea7157` checkpoints change only C++ + internals and have been + requalified by targeted native C++ gates; rerun the full Python suite on the + final candidate. + The installed-wheel target still has only historical evidence and is not + release evidence for this head. +- [x] Source-derived NFsim Issue78 coverage is green at `7186b65`: the direct + Python API and `simulate_nf` action both preserve output times + `[100, 101, 102]` and evaluate `time()`-backed synthesis from the absolute + start, while the CLI accepts a nonzero NF `--t-start`; the C++ equilibrate + test verifies duration-based equilibration from a nonzero current clock. + These are targeted local contracts, not independent native-NFsim parity. +- [x] The exact NFsim `IfTest/ifTest.bngl` source fixture now parses through + `build/cpp/bng_cpp --check` on `7186b65`, including its empty `reactant_1()` + placeholder declaration; parser acceptance is not execution parity. +- [x] The independent native-NFsim stochastic subset was rerun against the + absolute native binary `/Users/akutuva/Documents/BioNetGen/nfsim/build/NFsim` + (binary SHA-256 + `7302fe29b16d1ebe86369f752f2a49d2c87ef16539faaec11b82294a9fa56d22`) with + `NFSIM_BIN` set to that absolute path. The `motor` and `tlbr` Tier-NF + ensemble cases passed the declared 200-run gate, the direct/XML shadow + cases passed, and the fixed-seed direct endpoint cases passed: + `6 passed, 4 deselected, 6 warnings` in 131.87 seconds at `7186b65`. + This is subset evidence only; it does not close the full Tier-NF gate. +- [x] The C++-unchanged native checkpoint `c754544` passes the four-model local Tier-NF + 200-run gate (`simple_system`, `tlbr`, `motor`, `localfunc`) against the + independently built native binary: `4 passed, 6 deselected, 5 warnings` in + 168.62 seconds. Its direct-vs-in-memory-XML shadow suite passes `4 passed, + 6 deselected, 8 warnings` in 4.17 seconds. The focused localfunc XML output + is also accepted by that native binary; the focused native test passes + `1 passed, 9 deselected, 5 warnings`. These are current subset/checkpoint + results, not full approved Tier-NF or three-way parity evidence. +- [x] The source-derived NFsim Issue86 species-observable refresh test remains + green in the full current AST adapter run at `9378000` (13 assertions). + The independent native-NFsim cross-check on a reduced fixture derived from + `nfsim/test/Issue86/issue86.bngl` (seed `1`, `t_end=0.1`, 20 output + intervals, 4 observables) is historical evidence from `852793f`, producing + exact direct/native results at all 21 checkpoints; it has not been rerun + after the Issue78-only change. This closes only the targeted + dependency-refresh regression; broader direct-NFsim, protocol, and + three-way evidence remains open. +- [x] The validation harness now fails closed when `NFSIM_BIN` is missing or + invalid instead of silently selecting BNG3's embedded `build/cpp/NFsim`. + Source-derived path tests pass `5 passed, 1 skipped` in + `tests/validation/test_harness_paths.py`; the skip is the intentionally + absent local explicit oracle after the repair. The exact repair is + `b13fe23`; use an absolute independently built native path for claimed + parity. +- [x] Exact-head local CI workflow contract tests pass 7/7 on `9a2475a`, + including the pull-request source-distribution smoke gate and the contract + that PR-head concurrency preserves in-flight hosted evidence. +- [x] Source-derived Playground Atomizer `Species.extend` coverage passes + `4 passed, 34 deselected` in + `tests/python/test_modern_atomizer.py -k species_extend` after the expected + red-first run. The implementation is in + `python/bionetgen/atomizer/modern/structures.py` and follows the reference + branch `src/lib/atomizer/core/structures.ts:637-672`. +- [x] Local canonical Black check passes: `177 files would be left unchanged` + under `black --check --diff --target-version py312 python/ tests/python/ + scripts/` (Jupyter files are skipped because optional Jupyter dependencies + are absent); Ruff and git diff checks pass. The broader ad hoc check that + included `tests/validation/` remains red on pre-existing formatting drift + and is not the hosted CI command. +- [x] Local validation smoke on current semantic head `7186b65` reports 4 + passed, 15 skipped, and 174 deselected. The remaining skips are visible + `run_network`/reference-oracle gaps, with sandbox process-inspection noise + also present, and must not be treated as parity. +- [x] Current local validation smoke at BNG3 semantic head + `19a90ed83fad4529bb8e85e1a538e00ade274cb2` used + `PYTHONPATH=build/cpp:python python -m pytest -c tests/validation/pytest.ini + tests/validation -m smoke --bng-cpp build/cpp/bng_cpp -q` and reports `4 + passed, 15 skipped, 175 deselected, 1 warning` in 8.07 seconds. The local + `build/cpp/bng_cpp` artifact has SHA-256 + `8e80832c8a347a303fcfb21fa8c4c35a98b13ffd8967cc9192f964784287a7f3`. + Skips remain explicit missing-reference/legacy-oracle and sandbox + `run_network`/process-inspection gaps; this is smoke evidence only, not + complete parity evidence. +- [x] Non-strict provenance, corpus-manifest, generated-manifest, and exception + ledger checks pass. The strict provenance gate remains intentionally red with + 10 pending source/oracle/compiler/Python-lock approval errors; the exception + ledger itself is valid with 0 active entries under the CI budget check. +- [x] Fresh provenance-spine audit at semantic head + `871d261442be2f4808cbcabed51f6a017ba5488a` and public checklist head + `e95fe022292339c82d29fb9a22d73ea8dab9f9ed` used the exact commands + `python scripts/validate_provenance.py`, + `python scripts/validate_corpus_manifest.py`, + `python scripts/generate_corpus_manifest.py --check`, and + `python -m tests.validation.exception_ledger --max-exceptions 1`. They + reported `provenance validation passed: 1 source lock, 0 ledger(s)`, + `corpus manifest validation passed: 100 model(s)`, `corpus manifest is + current`, and `exception ledger valid: 0 active`. The baseline remains + pending maintainer approval. The corresponding strict command + `python scripts/validate_provenance.py --require-approved` still fails with + exactly 10 approval/lock errors for the baseline, five sources, two oracles, + compiler images, and the Python lock; this is a governance blocker, not a + silently accepted pass. +- [x] Fresh external-Perl Tier-P network parity at semantic head + `871d261442be2f4808cbcabed51f6a017ba5488a` used + `BNG2_PERL=/private/tmp/bng2-oracle.TToh58/source/bng2/BNG2.pl` from source + revision `fde0cd6a522c9f988d5495db31c70ce0f98e744b` and the exact command + `env BNG2_PERL=/private/tmp/bng2-oracle.TToh58/source/bng2/BNG2.pl NFSIM_BIN=/private/tmp/bng3-nfsim-3b046/build/NFsim PYTHONPATH=python:build/cpp python -m pytest -c tests/validation/pytest.ini tests/validation -m "parity and not slow" --bng-cpp build/cpp/bng_cpp -q`. + It completed `54 passed, 46 skipped, 94 deselected` in `103.14s`. The skips + remain explicit BNG2 fixture failures or missing references/support assets, + including legacy syntax, missing NFsim/run_network support, and the sandbox + `ps` restriction; they are not parity passes. The current exception ledger + is empty, so these remain environment/capability limitations rather than + silently admitted expected failures. +- [x] Fresh export-format validation at the same semantic head used + `PYTHONPATH=python:build/cpp python -m pytest -c tests/validation/pytest.ini + tests/validation -m export --bng-cpp build/cpp/bng_cpp -q` and completed + `12 passed, 182 deselected` in `12.18s`. This is export-format evidence only; + SBML-Multi, broader writer parity, and release qualification remain open. +- [x] Historical package evidence: a no-build-isolation sdist and wheel were + rebuilt from semantic checkpoint `ba52c20` and the wheel was installed into + an isolated target. These artifact digests and installed-wheel test results + are not current release evidence for 6b953c5. + Artifact SHA-256 digests are + `7ce09d700a5ff8fc42982c71eeaa448873811d4f3a10464bb67a8aac728a8780` + (sdist) and + `419bb2bd29f319bfc638c50b9c29cec0934b6d87eb7ce8fcdefed70a01f618c2` + (CPython 3.14 arm64 wheel); the installed-target Python suite is recorded + above. +- [ ] Superseded hosted checks for semantic head `c754544` were not terminal + as one set: [CI run + 33531304777](https://github.com/RuleWorld/BNG3/actions/runs/33531304777) was + cancelled, [CodeQL run + 33531304750](https://github.com/RuleWorld/BNG3/actions/runs/33531304750) + was still in progress at the last readback, and [formatting run + 33531304766](https://github.com/RuleWorld/BNG3/actions/runs/33531304766) had + passed. These runs do not qualify the current repair. +- [ ] Historical exact public semantic code checkpoint `73757ea` had hosted + evidence beginning with [CI run + 33539030113](https://github.com/RuleWorld/BNG3/actions/runs/33539030113), + [CodeQL run + 33539030155](https://github.com/RuleWorld/BNG3/actions/runs/33539030155), + and [formatting run + 33539030353](https://github.com/RuleWorld/BNG3/actions/runs/33539030353). + All three were queued at readback. Queued or partial results are not + completion evidence for any later head. +- [x] Historical hosted PR checks for semantic head + `0f833470950fc47329f5b7381c64533e623b45ce` were terminal-success: [CI run + 33493581633](https://github.com/RuleWorld/BNG3/actions/runs/33493581633) + completed all required C++, Python, ASan, integration, validation, lint, + package-smoke, and parse-inventory jobs successfully; release-only Docker, + source-distribution, wheel, and publication jobs were skipped by the pull + request event. [CodeQL run + 33493581605](https://github.com/RuleWorld/BNG3/actions/runs/33493581605) + passed both C++ and Python analysis, and [formatting run + 33493581573](https://github.com/RuleWorld/BNG3/actions/runs/33493581573) + passed. Results were read back with `gh` against the exact public head; +- [x] Historical readback before the later refresh: `gh api + repos/RuleWorld/BNG3/git/ref/heads/codex/bng3-integration-foundations` and + `gh pr view 2 --repo RuleWorld/BNG3` read back the same full public code + checkpoint SHA `73757ead732156f5d4b1a0f9a50901263631a93f`; PR #2 was open. +- [x] Modern Atomizer checkpoints exist for annotations, BNG-XML conversion, + Rulifier, UniProt, structure helpers, and conservative SBML-Multi discovery, + helper/rate-rule constants, each with source-derived tests. +- [ ] The release candidate has independent oracle, provenance-complete + golden, full parity, direct-NFsim, SBML-Multi, legacy, installed-package, + and release-artifact evidence. + +## 1. Authority, ownership, and source reconciliation + +### 1.1 Accepted source baseline + +- [ ] Maintainers choose exact accepted source cutoffs for BNG3, BioNetGen, + NFsim, PyBioNetGen, and RuleHub. +- [ ] provenance/upstreams.lock.yml changes from observed/pending status to an + approved baseline only after the decisions are recorded. +- [ ] The lock records repository URL, branch/tag, exact revision, observation + date, role, license/provenance note, and the reason for selecting the cutoff. +- [ ] The supported Python, compiler, operating-system, architecture, and + dependency matrix is approved and recorded. +- [ ] Public PyBioNetGen imports, result objects, CLI forms, defaults, + warnings, exceptions, and file behaviors are classified as supported, + deprecated, or intentionally private. +- [ ] The sanctioned BIONETGEN_USE_PERL=1 compatibility mode is classified as + a release feature or developer-only oracle path. +- [ ] Numerical tolerances, stochastic acceptance statistics, seed policy, + solver versions, and review owners are approved. + +### 1.2 Ownership and decisions + +- [ ] Every capability-matrix row has an owner, source path/revision, + implementation path, regression fixture, independent oracle, and acceptance + gate. +- [ ] CODEOWNERS or equivalent ownership exists for parser/AST, graph/network, + NFsim, expressions/solvers, Atomizer/SBML, Python/packaging, + CI/release/provenance, and documentation/compatibility policy. +- [ ] Architecture decisions are recorded for canonical AST boundaries, graph + identity, expression evaluation, direct NFsim lifecycle, writer ownership, + public API compatibility, and legacy retirement. +- [ ] Maintainer decisions in Section 12 of BNG3_INTEGRATION_PLAN.md are + recorded before release qualification. + +### 1.3 Reconciliation ledger + +- [ ] A non-empty reconciliation ledger exists for every selected source + repository under provenance/reconciliation/. +- [ ] Every post-baseline source commit is classified exactly once as + incorporated identically, incorporated equivalently, superseded, + not-applicable, pending-port, or blocked-on-design. +- [ ] Each ledger entry records source SHA, affected capability, BNG3 commit or + issue, tests, reviewer, rationale, and disposition date. +- [ ] Correctness/security, parser/semantic, numerical/NFsim, Atomizer/API, + packaging/platform, performance, and documentation changes are reviewed in + that priority order. +- [ ] A scheduled read-only upstream drift report compares locked SHAs with + current upstream heads without copying code, changing goldens, or pushing + fixes automatically. + +### 1.4 `akutuva21/bionetgen` non-main branch audit + +- [x] The fork was audited read-only through its exact branch tips and public + PR list on 2026-09-01. The fork is + `https://github.com/akutuva21/bionetgen`, with `master` at + `b00410628484f639efbf294f8a150f21c4e8bb29`; open PR heads are #508 at + `e67850cfc5b2d65322970b77b9e145152e2da0f6` and #509 at + `5cf5cd4747efa9961b8b3d63127499fce945000e`. Parallel branch tips were + recorded before porting; they are not treated as one mergeable stack. +- [x] Source commit `46da45c4` (`fix: handle empty pattern graph + canonicalization`) is ported equivalently at BNG3 `b610992`; the + source-derived empty-graph canonicalization test is green. +- [x] Source commits `556099d3` (`perf: reduce BNG2 graph string + allocations`) and `b73d9e3d` (`perf: streamline canonical node labels`) are + ported equivalently at BNG3 `2c498af`; exact BNG2-string and canonical-label + tests are green. These are performance ports with preserved output + contracts, not a claim of benchmark parity. +- [x] Source commit `77c8bd8` (`portability: constrain BNGcore inequality + overloads`) is classified non-applicable to the current BNG3 tree: BNG3 + already uses type/member-scoped inequality operators rather than the generic + overload removed by that source patch. +- [x] Source commits `5291159d` (compartment-aware dedup test) and `533ac26` + (`perf: skip canonical labels for exact product duplicates`) were ported + equivalently at BNG3 `084090e`, with the source-derived compartment-aware + dedup test and full CTest `176/176` green. +- [x] Source commit `92ca4c03` (`perf: preserve canonical product ordering in + exact dedup fast path`) was ported equivalently at BNG3 `d52f18a`, with the + source-derived no-canonicalization exact-probe test and full CTest `176/176` + green. +- [x] Source commit `70acc9e2` (`perf: reuse exact dedup keys across product + insertion`) was ported equivalently at BNG3 `7100f5e`: the exact-key output + overload and keyed insertion API are covered by a source-derived + `SpeciesList` test, and all three product insertion paths reuse the computed + key. BNG3 retains the required compartmented-species key recomputation after + canonicalization from the earlier `533ac26` port; full CTest `176/176` is + green. This closes the source/API slice, not independent benchmark parity. +- [x] Source commit `7ee2db11` (`perf: cache immutable reaction pattern + metadata`) was ported equivalently at BNG3 `73757ea`: cached immutable + `PatternInfo` is rebuilt by `initialize()` and reused by all former + per-expansion description sites, with source-derived reinitialization/move + coverage and full CTest `177/177` green. This closes the source/API slice; + independent BNG3 benchmark reproduction remains open. +- [x] Source commit `f30898b6` (`Bolt: Optimize string lowercasing + allocations in engine loops`) was ported equivalently at BNG3 `2d69b99` for + `parseBooleanLike`; the source-derived accepted-spelling test remains green. + Its ODE allocation portion was reconciled with the newer ODE match work + below rather than duplicated. +- [x] Open Bolt PR #508 head + `e67850cfc5b2d65322970b77b9e145152e2da0f6` and PR #509 head + `5cf5cd4747efa9961b8b3d63127499fce945000e` were audited and their supported + allocation-only ODE portions were ported equivalently at BNG3 `d502e47`: + lowercase function names are prepared once, raw rate-law matching avoids a + lowercased temporary, and the source-derived user-defined ODE-rate contract + passes. A probe that changed the declared function's case was rejected from + the port because BNG3's parser/runtime function-name semantics are not + case-insensitive; no public language behavior was silently broadened. +- [x] Source commit `60ac7e5f` (`Bolt: Pre-parse observable patterns in loops`) + was ported equivalently at BNG3 `4ea7157`: ODE group compilation and NetWriter + group serialization cache parsed graphs while retaining compartment, + quantifier, state, structural-role, and species-observable behavior. The + source-derived multi-pattern ODE and repeated-pattern NetWriter contracts + pass in the targeted `6/6` CTest selection; independent BNG3 benchmark + reproduction remains open. +- [x] Branch `codex/portable-cpu-20260831` at + `305b7482febe3dd52ccd517fa4cd2e02504e834c` was audited. Its listed + exact-dedup/canonical-label source commits are ported above; remaining + branch content is documentation/benchmark material plus the separate + `0463a1f4` Macro allocation candidate. Independent BNG3 benchmark evidence + and the supported Macro executable path remain open below; the branch is not + a clean merge target. +- [x] Branch `codex/ode-integration` at + `9c7c0aa3e031330b7421a8e93a2340dc65c43cbb` and source commit `dd665873` + were audited and ported tests-first at BNG3 `3b284a5`. The focused + multi-species 512-reaction derivative contract and full CTest `175/175` are + green; representative performance benchmark evidence remains open. Branch + `codex/ode-jtimes-20260901` at + `fde0cd6a522c9f988d5495db31c70ce0f98e744b` adds no code beyond that ODE + lineage and must not be bulk-merged. +- [x] Branch `codex/graph-string-20260901` at + `62f4dc6bd2a191d89a593e2d952e6c74c5b47271` and + `codex/canonical-redesign-20260901` at + `901ce2d94db6d423e81745cb5f1ef1e82e1c865a` were audited. Their residual + tips are documentation-only records beyond the already ported Node/string + and canonical behavior; no unreviewed source change is pending from them. +- [x] Branch `codex/parallel-optin-20260901` at + `e52e8e41751f389eb8187b7e6d0ab946f0e5b8e8` was classified as an opt-in + independent-model launcher/benchmark branch, not a default runtime port. + Branch `codex/gpu-optin-20260901` at + `0c5217531ebb9e692cc5cff4d76535ed8b4cca91` is documentation-only and + records no retained GPU capability; both require a separate approved + capability/performance decision before inclusion. +- [x] The remaining non-main fork tips were audited read-only, including issue + branch `codex/bionetgen-issues-20260901` at + `1f92663066cfccfa33876f8e3b61c8dd21e10742`, ODE-JTimes branch + `fde0cd6a522c9f988d5495db31c70ce0f98e744b`, closed/superseded Bolt heads + `8aff40f6ddc017ece0eb03c5251edbcd0a27dd82`, + `06d8c9f2947594b38664c929c625d9164e09fd91`, + `0351eba9d4827c6c86e07fb06a1c56cd9dfd2160`, + `07240c0774d51073259205cf78f44e31314d5a98`, and + `abecfecb339f7ba2dc66580abf4c584f859caf0b`, plus the Jules and Sentinel + security branches. Source changes were classified as already ported, + superseded, non-applicable to BNG3, or pending below; `.jules` and generated + artifacts were not imported. A durable per-commit reconciliation ledger is + still required by Section 1.3. +- [x] The issue branch's native fixes were reconciled: empty-graph handling, + pure-bond rejection, and CLI action exception reporting are already covered + by BNG3 checkpoints `b610992`, `0f83347`, and `1c03bc1`. Its BNG2 `Network3` + memory/tfun/output fixes are not native BNG3 source and remain a separate + legacy compatibility audit. +- [x] The exact issue-branch tip `20fe141452e79d01fd4a669d801da59c73d38588` + was re-audited against the BNG3 tree. Its native changes are already + represented by the checkpoints above; the repeated generic-`operator!=` + portability patch `897e8a29a93dc42db8c1af74b0fbce968e52cb23` is likewise + non-applicable because BNG3 has type/member-scoped inequality operators. + No native issue-branch code was bulk-merged. +- [x] Source performance commit + `5fab87788a4d6253ea83fd2cb35312be0c99c725` (`Cache OdeIntegrator rate-law + normalization`) is ported equivalently at BNG3 + `1de39f4d3bb040dc5a2844c7432aadba5595d4c6`. The source parent is + `31155dc049a77057eb9a0afa5a41bf44b7bbaa81`; BNG3 adds the explicit + `` dependency, retains the existing lowercase-function and raw-rate + caches, and records the completed function scan so the fallback block cannot + rescan a rate expression. The source-derived case-classification contract is + `tests/cpp/test_ode_options.cpp:107-158`; it was green before the production + edit with `8 assertions`, and the focused post-edit target is `9/9` CTest + cases. A temporary benchmark harness, removed before commit, generated the + production `models/nfkb_illustrating_protocols.bngl` network (22 species, + 31 reactions) and constructed 512 OdeIntegrator instances per fresh process. + Twelve alternating baseline/candidate pairs using `/usr/bin/time -p` gave + coarse real-time medians of `0.78s` without and `0.775s` with the guard; + this is a sub-percent signal under timer noise, not a release performance + claim. Temporary baseline/candidate test artifacts were hashed as + `404777ab7161acc855830bdbb934adb9b9d73054d77a140308b3e4dc73c3e65f` and + `8d0c7e06944cb013ae6b97052ea7312a79b7c7331c6e3d7b113be521455342c2`. + Full local gates at the candidate report `187/187` CTest and + `276 passed, 27 skipped, 8 warnings` in Python. This closes the source/API + reconciliation only; cross-platform performance budgets, release + reproducibility, and broader benchmark provenance remain open. +- [x] Sentinel ContactMap server branches were classified non-applicable to + the current BNG3 tree, which has no `parsers/ContactMap/server.py`; their + exact security findings remain recorded for inventory. The Sentinel Perl + open-injection branch was applicable to the bundled legacy Macro module and + is ported and tested below. +- [x] The applicable legacy Perl open-mode hardening is ported and tested at + BNG3 `44d8655`. Sentinel commit `cdbd6ee98a7a546a7c8fa774e8d97bec6d9104d0` + is an empty duplicate of payload commit + `2b95afe90a41b52343596ac4adf396782e648a43`; the final contract also retains + the prior `.rab` diagnostic correction from `b13533cc`. The source-derived + `tests/python/test_legacy_security_contract.py` and Perl syntax gate pass. + Unrelated ContactMap server fixes remain non-applicable to BNG3. +- [x] Reconcile the applicable Macro portion of source performance commit + `0463a1f4906a7e4e0d51a4ac79fe16fee6a58ac` + (`num_site`/`cor_net` allocation changes) at BNG3 `4edf4df`: the + source-derived Macro executable contract now links, `trans_specie` is + implemented, and the source `pre_rules`/`pre_obs1` calls are active. + `num_site` uses the source `find`/`rfind` extraction and `cor_net` already + had the equivalent allocation-free extraction. The source ODE allocation + portion is reconciled separately with BNG3's newer ODE matching/cache path; + independent benchmarks and full Macro/legacy parity remain open. +- [x] The NFsim XML energy bridge is implemented at semantic code head + `567b39105bebaf9dd090104a584da9f9f424088a` and its Arrhenius directionality + repair is at `5a1594f39d0d51b1220e7f5278302cad253fed3f`. The source contract + is diagnostic BNG2 revision `fde0cd6a522c9f988d5495db31c70ce0f98e744b` + (`bng2/Perl2/BNGOutput.pm:619-630`, `EnergyPattern.pm:146-164`, and + `RxnRule.pm:1737-1919`) plus the accepted NFsim energy source cutoff + `3b046fc1b9f76719d92be22279b24992cdae7c35` (`src/NFinput/NFinput_energy.cpp`); + no later NFsim source was imported. `XmlWriter` now emits BNG2-shaped + ``/`EPn`/nested pattern graphs, preserving omitted + bond `numberOfBonds="0"` semantics from the source pattern instead of the + AST wildcard serializer. The XML loader routes explicit Arrhenius + `StateChange` operations through the existing energy expansion helper, + continues to later reaction rules, applies XML rate/match metadata, and + carries BNG3-generated one-way directionality with + `energyIncludeReverse="0"`; files without that attribute retain the legacy + reverse-on default. Red-first executions were + `ctest --test-dir build -R '^NFsim XML bridge preserves energy patterns$' + --output-on-failure` (7 failed assertions), + `ctest --test-dir build -R '^NFsim XML bridge expands Arrhenius state + changes$' --output-on-failure` (1 failed assertion, XML had 0 reactions), + and the one-way direction test (2 failed assertions). Final focused bridge + coverage reports `3/3`, the adjacent energy/Arrhenius selection reports + `17/17`, `ctest --test-dir build --output-on-failure` reports `190/190`, and + `env PYTHONPATH=python:build/cpp python -m pytest -q tests/python` reports + `276 passed, 27 skipped, 8 warnings` in `10.22s`. The rebuilt + `build/cpp/bng_cpp` digest is + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`, and + the final temporary `isingspin_energy` XML digest is + `c5d60e576023b06cb620b7a2f0794ff34a88642c9869eefa170e48fc4ba35f0d`. + A paired BNG3 direct-versus-forced-in-memory-XML `isingspin_energy` harness + using seeds `1..200`, `t_end=1.0`, `20` output steps, and four workers + reports `200/200` runs, `different_runs=0`, `max_per_run_diff=0.0`, and + `max_final_abs_diff=0.0`; output is retained in + `/private/tmp/bng3-energy-final-ensemble.log`. This closes only BNG3 + direct/XML semantic parity. The independently built accepted-cutoff NFsim + binary `/private/tmp/bng3-nfsim-3b046/build/NFsim` (source + `3b046fc1b9f76719d92be22279b24992cdae7c35`, SHA-256 + `c30a80b6ff9cf1fae04bc9f45556c4d5fa9c3b00d053fb6abade80436ee46394`) still + skips XML Arrhenius state-change expansion and leaves `0 reactions`; its + fixed-seed comparison differs by at most `8` and its 200-run comparison + had `60/105` points outside `3` pooled SE, worst `z=53.88` at `Misaligned`. + Independent NFsim energy parity, legacy XML without the direction marker, + broader evaluator coverage, and benchmark/provenance approval remain open. + +## 2. Independent validation and provenance spine + +### 2.1 Independent oracles + +- [ ] BNG2 Perl is built from the accepted BioNetGen source revision with a + recorded recipe, compiler/runtime details, artifact digest, and retention + location. +- [ ] Native pre-convergence NFsim is built independently from the accepted + NFsim revision with the same evidence. +- [ ] The accepted PyBioNetGen source/release is available for public API and + CLI compatibility comparisons and has a recorded digest. +- [ ] BNG3-generated output is never the sole oracle for BNG3 behavior. +- [ ] RuleWorld/bngplayground and Jules Playground remain independent + differential references, not scientific replacements for BNG2/NFsim. + +### 2.2 Corpus and goldens + +- [ ] RuleHub selectors and external tier membership are maintainer-approved. +- [ ] The generated selection manifest is linked to the accepted source lock + and has verified content digests for every fixture. +- [ ] Tier-S is feature-balanced and completes within its declared budget. +- [ ] Tier-P contains the complete approved BNG2-compatible corpus and models/ + fixtures, including all known graph-overcount cases. +- [ ] Tier-NF contains the approved NFsim corpus and all required NFsim + function/rate-law fixtures. +- [ ] Tier-X covers BNGL, BNG-XML, NET, SBML, SBML-Multi, Atomizer, and every + supported writer/converter. +- [ ] Tier-B covers benchmarks, large models, memory stress, sanitizers, leak + checks, and reproducibility rebuilds. +- [ ] A reviewed provenance-complete golden bundle exists. Each manifest + records model/content digest, RuleHub and source revisions, oracle/binary + digest, compiler/dependency/platform/image details, command, method, seeds, + time grid, tolerances, comparator version, output digests, and generation + metadata. +- [ ] A clean machine can verify the golden bundle without regenerating it. +- [ ] Golden regeneration is an explicit reviewed scientific change and never + occurs silently in ordinary tests. + +### 2.3 Comparator and exception integrity + +- [ ] Parser comparisons cover success/failure, normalized diagnostics, symbol + resolution, defaults, actions, functions, includes, and source-sensitive + behavior. +- [ ] NET comparisons are graph-aware and preserve species/reaction + multiplicity, stoichiometry, rates, compartments, observables, and + duplicates. +- [x] NET validation performs actual write/read/write idempotence at BNG3 + checkpoint `845bc8c`: `tests/validation/test_export_formats.py` now writes + a generated `.net`, creates a separate `readFile`/`writeNetwork` BNGL source, + reads that exact file through `runner.run_cli_path`, and compares the two + graph-aware parses. The source-derived BNG2 read/write contract is represented + by `tests/validation/Validate/michment.bngl` and + `tests/validation/Validate/michment_cont.bngl`; the exact local gate + `PYTHONPATH=python:build/cpp python -m pytest tests/validation/test_export_formats.py -q` + reports `12 passed` (four XML, four SBML, and four actual NET round trips). + This closes the validator-contract gap, not complete independent Tier-P + NET parity. +- [ ] Deterministic trajectories align explicit time points and compare all + contracted observables/species at approved absolute and relative tolerances. +- [ ] Expression/rate-law validation compares direct expression vectors/RHS, + not only downstream trajectories, at the approved 1e-9 criterion where + applicable. +- [ ] Stochastic validation uses fixed, predeclared ensembles; verifies + repeatability; compares means, variances, relevant quantiles, + extinction/zero-inflation behavior, and time-correlated summaries; and uses + the approved pooled independent-ensemble standard error. +- [ ] Designated stochastic gates contain at least 200 complete runs per side + or have an approved power-based alternative. +- [ ] Every skip and expected failure is in the machine-readable ledger with + exact test/model/platform scope, issue, technical reason, owner, + introduction date, expiry/review date, and expected signature. +- [ ] Required missing oracles, corpus files, schemas, or validators fail the + claiming job; they do not become passing skips. +- [ ] The exception budget is non-increasing unless a maintainer-approved + compatibility decision changes it. + +## 3. Parser, AST, graph identity, and network generation + +### 3.1 Canonical parsing and model semantics + +- [ ] ANTLR BNGL parsing is the sole default front door for BNGL into the + canonical ast::Model. +- [ ] BNGL syntax, diagnostics, block aliases, includes, actions, parameters, + compartments, molecule types/states, seed species, observables, rules, + functions, protocols, scans, and source metadata are covered. +- [ ] Invalid constructs fail with stable, documented diagnostics rather than + being dropped or routed to a weaker parser. +- [ ] ModelBuilder, Python load(), Atomizer output, CLI input, network + generation, simulation, and writers consume explicit canonical AST + boundaries. +- [ ] Parser/AST behavior is differentially compared with accepted BNG2 and + PyBioNetGen behavior for the supported contract. + +### 3.2 One graph canonicalizer + +- [ ] One bng::core::canonicalLabel implementation is used by network + canonicalization and NFsim complex identity. +- [ ] The duplicate cpp/nfsim/nauty24 build is removed only after independent + identity evidence is green. +- [ ] NFsim private canonicalization is replaced or explicitly governed without + changing complex identity semantics. +- [ ] The HNauty largest-versus-canonical-form decision is resolved against + BNG2 semantics and recorded. +- [ ] Species graph equality preserves site states, bonds, connectivity, + stoichiometry, compartments, and symmetry; it does not reduce to string + normalization. +- [ ] blbr, Motivating_example_cBNGL, test_network_gen, tlbr, and all other + known overcount fixtures match the independent BNG2 oracle. +- [ ] Tier-P NET parity passes across the complete approved corpus with no + unreviewed overcount exception. +- [ ] A single low-level Nauty dependency is proven not to alter NFsim + runtime identity, reaction counts, or seeded trajectories. + +### 3.3 Network and numerical simulation + +- [ ] Network generation matches accepted BNG2 species/reaction sets and + multisets, including deletion, product molecularity, bond cardinality, + symmetry, observables, fixed species, compartments, and rate laws. +- [ ] ODE/CVODE, SSA, PLA, and PSA methods preserve documented controls, + output shapes, sample grids, conservation behavior, and failure modes. +- [ ] Protocols, time-dependent functions, events, scans, continuation, and + solver options are covered by source-derived and independent tests. +- [ ] One-dimensional and two-dimensional parameter scans match the supported + PyBioNetGen contract. +- [ ] Local sensitivity analysis matches the supported finite-difference + contract, including parameter ordering, perturbation controls, and result + schema. +- [ ] Benchmarks establish performance and memory budgets for representative + small, medium, and large models; abstractions do not silently regress them. + +## 4. One expression and rate-law contract + +- [ ] A single parsed/resolved expression representation and error model is + shared across ODE RHS, SSA/PLA/PSA propensity evaluation, and NFsim + local/global functions. +- [ ] NFsim ExprTk compilation and the NFSIM_USE_EXPRTK build path are removed + only after the shared evaluator passes all dependent gates. +- [ ] Numeric literals, parameters, observables, time, roots, logs/bases, + constants, function definitions, nested functions, and domain errors have + cross-backend tests. +- [ ] Global functions, local functions, molecule/species scopes, TFUN linear + and step forms, file-backed files, observable/time/parameter counters, + composite functions, bounded nested functions, and function-counter forms + match independent references. +- [ ] Michaelis-Menten, Sat, Hill, elementary, FunctionProduct, Arrhenius, + energy-pattern, reversible, zero-order, and scoped-rate laws are validated. +- [ ] Unsupported function/rate-law combinations remain fail-closed with a + diagnostic and a tracked capability status. +- [ ] Direct expression-vector/RHS parity reaches the approved tolerance for + localfunc, isingspin_localfcn, isingspin_energy, CaOscillate_Func, and all + approved test_tfun_* fixtures. +- [ ] Direct NFsim function-bearing models localFunction, motor, TQSSA, and + the accepted NFsim test corpus pass through the shared contract. + +## 5. Direct NFsim convergence + +### 5.1 Adapter completeness + +- [ ] Typed direct mapping covers options and flags, parameters, compartments + and hierarchy, molecule types and integer/symmetric states, seed species, + populations, fixed species, observables, reaction rules, transformations, + functions, TFUNs, rate laws, energy patterns, and runtime metadata. +- [ ] Direct mapping preserves bond labels/cardinality, product molecularity, + deletion modes, symmetry factors, molecule/template observables, + include/exclude filters, compartment movement, and MoveConnected behavior. +- [ ] Direct mapping preserves dynamic rates and generated live functions for + symmetric state-change/bond permutations. +- [ ] Unsupported local-function, TFUN, energy, rate-law, filter, and scope + combinations fail closed and are listed in the capability matrix. +- [ ] Adapter ownership, destruction/lifecycle, memory ownership, diagnostics, + seed handling, options, and error propagation are documented and tested. + +### 5.2 Three-way evidence + +- [ ] Direct ast::Model construction is compared with the existing + AST-to-XML-to-NFsim path in memory and, where required, on disk. +- [ ] Direct construction is compared with an independently built native NFsim + oracle, not a BNG3-generated oracle. +- [ ] Comparisons cover molecule types, seed complexes, transformations, + observables, functions, compartments, options, reaction rules, seeded + deterministic behavior, and stochastic distributions. +- [x] Current exact seeded native-NFsim trajectory parity is closed for the + source-derived `IfTest` fixture at `e92b2c9`. Direct BNG3 loaded the exact + `nfsim/test/IfTest/ifTest.bngl` source through `bng_cpp --console` and ran + with seed `1`, `t_end=5`, ten output steps, and `-utl 3`; independent native + NFsim `a6f9fa9` ran the unmodified XML oracle from the same fixture with the + same seed/time grid. Both produced 29,177 events and byte-identical output; + the direct BNG3 `.gdat` and native artifact have SHA-256 + `6f262eaf40044ba844063f6f572d4a58fde3a5d814011e6da40d40b75d54abe4`. + BNG3 direct and explicit XML fallback also produced the same digest. The + historical checkpoint at `300724b` remains recorded separately by digest + `85fef92118f5effffec6e8f179c0912f4f28efd5a53c2b5a79ef9807de6ffe88` and + `Ton = 0, 2236, 3944, 6306, 7787, 8656, 9176`. The source-derived + `stepTo` event-cache port from NFsim `e3ef4a0` preserves + continuous-vs-chunked checkpoint timing. BNG3 mirrors current NFsim's split: + reaction timing/selection stays per-System, while legacy molecule/mapping + selectors identified in source commits `64d225f` and `a6f9fa9` use a second + per-System stream seeded identically (exact direct and XML assertions in + `tests/cpp/test_nfsim_ast_adapter.cpp`). +- [ ] Tier-NF includes localfunc, motor, TQSSA, tlbr, simple_system, + fceRI/multisite fixtures where supported, and the relevant nfsim-master/test + inputs. +- [x] The source-derived AN2 trajectory is exact through the direct AST at + current checkpoint `e92b2c9`. The unmodified NFsim fixture + `nfsim/test/AN_chemotaxis/an2.bngl` was parsed by BNG3 `e92b2c9` and run + with seed `1`, `t_end=10`, and ten output steps; an independent native + NFsim `a6f9fa9` run from `nfsim/test/AN_chemotaxis/an2.xml` used the same + seed/time grid. Both produced 7,807 events and byte-identical `.gdat` + output with SHA-256 + `0fdb80a8e151a30b3051be2e1fced2dafc6ccc5223e96e4d474ab5f386032d64`. + The discriminating source ports were position-major repeated-seed + allocation (`bdddbd4`), BNG2 canonical seed graph/type ordering + (`41b12f2`), and numeric-site wildcard/PLUS/MINUS handling (`3be1b40`). +- [x] Fixed-seed direct/API NFsim endpoint parity is closed for the source- + derived `motor` and `tlbr` fixtures at `7186b65`. With seed `1`, the + independently built native binary above, BNG3-generated XML, and twenty + output checkpoints, `tests/validation/test_parity_nfsim.py::test_nf_fixed_seed_direct_matches_native_at_final_endpoint` + passes with exact observable arrays and time coordinates within `1e-12`. + The full `motor`/`tlbr` subset passed `6 passed, 4 deselected, 1 warning` in + 124.43 seconds. The direct binding invokes endpoint-inclusive `stepTo` only + for the final checkpoint, while the ordinary one-argument `stepTo` contract + remains exclusive for intermediate callers. The full Tier-NF gate remains + open. +- [ ] Protocol NF support and remaining RNA/t4/t5 behavior are + implemented or explicitly governed with tests and owners. +- [ ] The full fixed-seed and distributional Tier-NF gate passes at the + approved criteria. + +### 5.3 XML bridge retirement + +- [ ] The direct path is demonstrably selected and the test asserts the actual + construction route. +- [ ] The XML bridge remains available only as a temporary shadow comparator + and supported interchange path during migration. +- [ ] Direct and XML paths are statistically identical on the approved shadow + corpus. +- [ ] Only after the full gate passes, remove XML serialization, temporary-file + machinery, and XML reparse from the default NF simulation path. +- [ ] Retain BNG-XML export/interchange support and its schema/semantic tests. + +### 5.4 Energy-function evaluator convergence + +- [x] The merged `akutuva21/nfsim` energy-evaluation source is pinned for this + work: PR #475, merge `6690fda5d9e053df822d0248ebae185f5caca82a`, accepted + energy-source cutoff `3b046fc1b9f76719d92be22279b24992cdae7c35`. +- [x] The accepted PR #475 source is already reconciled equivalently in BNG3: + the compact evaluator, partner-pool scale groups, and direct-selector + integration are represented by the source-derived BNG3 checkpoints through + `a97c02e`, with the corresponding AST/lifecycle/RNG adaptations retained. + A file-level comparison against the accepted NFsim cutoff found no missing + PR #475 implementation that should be merged wholesale. This closes source + incorporation only; independent native-NFsim parity, benchmark provenance, + and the remaining evaluator slices below remain open. +- [x] Source-derived BNG3 tests define compact binding-context extraction, + rejection of duplicate weighted molecule topologies, compact conjunction + masks, factorized runtime propensity evaluation, shared partner-pool + registration/indexing and selector batch updates, and materialized fallback. + Current checkpoints are `b9ab125`, `2b1c02f`, `92543ca`, `6ed6e97`, + `a77ceb8`, `b8f44e4`, `4a2fc3e`, `738c881`, `6b6e246`, `bd29714`, + `401becf`, `6c681269`, `a97c02e`, `7b2a199`, `dbadea6`, `c0d1bb5`, + `bb3ae01432adfd8bb92240af3e1e947e49b017ee`, `464bd8d`, and `2940a02`. + The source-derived MoleculeList ownership/reuse regression is covered at + `f510e49` and fixed at `ec42926`. +- [x] BNG3 carries the compact `EnergyBindingContext` and mapping-local + `EnergyRxnClass` path for supported contexts while retaining legacy + materialized expansion for unsupported topologies. +- [x] BNG3 carries the compact partner-pool index for simple forward binding + rules, including shared pool registration and batched selector updates on + partner add/remove membership changes. +- [x] BNG3 carries a source-derived deferred membership lifecycle for compact + direct-product events, including coalesced partner-pool changes and selector + updates that capture BNG3's live compact propensity before membership + mutation (`6c681269`; `tests/cpp/test_nfsim_ast_adapter.cpp`, 30 assertions). +- [x] BNG3 carries source-derived sparse direct-selector behavior for compact + reactions: active propensity bits, block prefix sums, cached sparse + propensities, indexed updates, and shared compact-pool scale groups + (`a97c02e`; `tests/cpp/test_nfsim_ast_adapter.cpp`, 51 assertions). +- [x] BNG3 carries the accepted NFsim `4bb24b3119684e9ec6e870bb4b517866e2aa15a4` + sparse-batch refinement: indexed sparse updates use the selector's cached + pre-update propensity instead of rereading a post-event `get_a()` value. + The source-derived fired-event regression is + `NFsim sparse selector reuses cached propensities in implicit batches` in + `tests/cpp/test_nfsim_ast_adapter.cpp`, implemented at `88f4e54` and green + in the exact-head `184/184` CTest gate. +- [x] BNG3 carries the accepted-cutoff NFsim + `301bfbeb5ec5007532f713f488ff9954da9ebe1f` guarded Release-LTO build + capability at `7241746`: `CheckIPOSupported` controls the embedded + `nfsim_core` and each built consumer, with explicit ON/OFF and optional + standalone-NFsim contract coverage. This is build/performance evidence + only; reproducible speedup, memory, and cross-platform benchmark evidence + remain open. +- [x] BNG3 carries source-derived compact reverse propensity specialization + and factorization guards (`dbadea6`), plus indexed cross-type partner + endpoint propagation and a dense type-invariant membership-decision cache + (`bb3ae014`; `tests/cpp/test_nfsim_ast_adapter.cpp`, 15 mixed-fixture + assertions). +- [x] BNG3 carries source-derived pure-context counting for transformed-rule + discrimination and complex-level deduplication (`bb14a207`): homodimer, + homotrimer, distinguishable scaffold, transformed homodimer binding, and + local-function DOR fixtures pass in + `tests/cpp/test_nfsim_ast_adapter.cpp` (18 assertions across the four + pure-context cases). +- [x] BNG3 carries source-derived sparse membership-decision indexing from + NFsim commit `ba7466c386c0cf72920863472d8382f8011e1811`: type-invariant + direct-product decisions retain an ordered affected-reaction index list when + fewer than half the registered reactions are affected, and the adapter + refreshes that list without scanning unrelated rules (`fbfda3f`; the + unrelated-partner fixture passes 42 assertions in + `tests/cpp/test_nfsim_ast_adapter.cpp`). +- [x] BNG3 carries the endpoint-refined membership decision from NFsim commit + `ced6f6046dc3e9a5bf1680d9367ecdf64facd7a4`: partial context changes are + rejected when the changed molecule remains context-incomplete, while the + full-mask case retains the source fallback (`464bd8d`; the compact + factorized-energy fixture passes 49 assertions in + `tests/cpp/test_nfsim_ast_adapter.cpp`). +- [x] BNG3 carries the source-derived all-forward compact partner-pool early + return from NFsim commit `fd01d015ea70552fe2196a1029317ac8f08674fe`: + when every candidate reaction uses the shared compact pool, only that pool's + registered reactions are refreshed before returning from generic membership + scanning (`2940a02`; the direct AST fixture passes 18 assertions in + `tests/cpp/test_nfsim_ast_adapter.cpp`). The fixture uses bidirectional + energy rules to exercise both directions; one-way direct-AST energy mapping + is covered separately below. +- [x] BNG3 carries the source-derived connected-membership refresh from NFsim + commits `051e7e2` and `23436e2`: native MoleculeType reaction order, + precomputed `areReactionsConnected` lookup, compatible explicit template + connectivity, and indexed `tryToAddWithIndex` for incremental reactions + (`c7dd52d`; the source-derived t3 XML bridge test passes bytewise seeded + no-connect/connect trajectory comparison in + `tests/cpp/test_nfsim_ast_adapter.cpp`). +- [x] BNG3 carries the source-derived functional symmetry/TotalRate correction + from NFsim commits `2778162` and `1b19611`: constructor symmetry factors + land on the member rate, ordinary functional propensities apply that factor, + and TotalRate propensities do not (`53a3d3d`; the XML bridge fixture in + `tests/cpp/test_nfsim_ast_adapter.cpp` passes both cases, based on + `test/symmetry/symmetry_factor_total_rate`). +- [x] BNG3 carries the source-derived reusable connectivity direct-product + lookup scratch from NFsim commit `96be0b1`, while retaining the ordered + direct-product vector required by compact energy preparation (`a2d7f6c`; + `tests/cpp/test_nfsim_ast_adapter.cpp`, 11 assertions in the repeated + connectivity refresh fixture). +- [x] BNG3 carries compact sorted reaction-membership IDs from NFsim commit + `ad4b56a`: the direct-AST adapter uses a dense first-ID plus inline/overflow + representation, preserving ordered iteration and set semantics while + avoiding one heap-backed ordered container per mapping index (`3fb3373`, + `0560c3b`; `tests/cpp/test_nfsim_ast_adapter.cpp`, compact-ID and inline-ID + fixtures). +- [x] BNG3 carries the source-derived lazy direct-product molecule lookup from + NFsim commit `2b3c643`: the connectivity-only hash set is allocated on first + use and remains separate from the compact ordered direct-product vector + (`2c0b999`; compact factorized-energy and repeated connectivity-refresh + fixtures pass without changing event behavior). +- [x] BNG3 preserves BNG2 one-way Arrhenius directionality for the supported + direct AST binding and state-change slices: one forward reaction is emitted, + no implicit reverse reaction is synthesized, and contextual compact binding + remains forward-only. Source-derived BNG2 formulas are locked by the three + AST fixtures in `tests/cpp/test_nfsim_ast_adapter.cpp` (21 assertions), with + implementation at `72dd033` and compact-path coverage at `ba52c20`. +- [x] BNG3 ports NFsim commit `4b4e514` product-molecularity evaluation for + multi-bond dissociation: all bonds deleted by one firing are excluded from a + single connectivity check, allowing genuine ring opening while retaining the + single-bond ring rejection. Direct AST and XML-compatibility fixtures in + `tests/cpp/test_nfsim_ast_adapter.cpp` pass 30 assertions at `6b953c5`. +- [ ] Port and test the remaining supported CPU evaluator slices from the + merged NFSIM source: the broader full incremental-membership machinery and + the remaining direct-product paths. The connected direct-product refresh + path and reusable direct-product lookup scratch are now covered above, but + full source parity is not implied. + Source context-count semantics now have a BNG3 adapter port and focused + source-derived tests, but their full source parity is not implied. + Cross-type changed-endpoint propagation is now indexed in the + BNG3-adapted path, but source parity is not implied. + Direct-product endpoint identity is snapshot-tested and + propagated through fired membership refresh at `4a2fc3e`, safe direct-product + traversal is checkpointed at `738c881`, cached single-/multi-term rate factors + at `6b6e246`, cached simple pre-fire binding rejection at `bd29714`, and + candidate bitset/mapping-slot indexing at `401becf`, deferred multi-product + propensity accounting at `6c681269`, sparse selector integration at + `a97c02e`, cached implicit sparse-batch old-propensity reuse at `88f4e54` + (NFsim `4bb24b3`), reverse specialization at `dbadea6`, partner endpoint/indexed + decision refresh at `bb3ae014`, pure-context counting at `bb14a207`, and + sparse membership-decision indexing at `fbfda3f`, and all-forward compact-pool + early return at `2940a02` from NFsim `fd01d015`, compact sorted/inline + reaction-membership IDs from NFsim commits `ad4b56a` and `4007795` + (`3fb3373`, `0560c3b`), and lazy direct-product lookup allocation from + NFsim commit `2b3c643` (`2c0b999`); + the other listed slices remain open. + Preserve BNG3 lifecycle and direct-AST adapters while porting. +- [ ] Compare compact and fallback event semantics against an independently + built native NFsim at the pinned source revision, including zero crossings, + conjunction contexts, reversible binding/unbinding, RuleMonkey selection, + complex-bookkeeping modes, and fixed-seed trajectories. +- [ ] Record the energy benchmark fixture, compiler/platform, event counts, + memory, and non-additive CPU measurements with reproducible artifact + digests; performance evidence must not substitute for parity evidence. +- [ ] Reconcile every remaining energy-specific source commit as identical, + equivalent, superseded, not-applicable, pending-port, or blocked-on-design + in `provenance/reconciliation/`. + +## 6. Python API, CLI, and compatibility consolidation + +### 6.1 Public contract + +- [ ] Inventory current BioNetGen/PyBioNetGen documentation, examples, + downstream imports, and CLI usage. +- [ ] Freeze supported signatures, defaults, result objects, array shapes and + dtypes, parameter ordering, warnings, exceptions, serialization, context + management, and file behavior. +- [ ] load(), ModelBuilder, model.simulate(), scans, sensitivity, exports, + visualization, and checks route through the canonical in-process backend. +- [ ] All methods ode, ssa, pla, psa, and nf are tested through both Python + API and CLI where applicable. +- [ ] CLI run, scan, sensitivity, visualize, check, and export commands work + on the approved Tier-S and representative Tier-P fixtures. +- [ ] Plotting/helpers, embedded notebooks/data, optional integrations, and + result display behavior have explicit supported/deprecated status. +- [ ] The default package import has no hidden import of legacy parse or + simulation modules. +- [ ] BIONETGEN_USE_PERL=1 remains an isolated, tested compatibility/oracle + path with explicit warnings and no default-path leakage. + +### 6.2 Legacy implementation removal + +- [ ] Search proves zero default-path references before deleting any legacy + implementation. +- [ ] Contract tests pass before removing python/bionetgen/modelapi/, + python/bionetgen/network/networkparser.py, python/bionetgen/simulator/, or + legacy Cement parser/simulation entry points. +- [ ] The sanctioned compatibility runner and only the needed core exceptions, + defaults, result, plot, or notebook helpers are retained deliberately. +- [ ] Deletion lists, deprecation warnings, migration guidance, and release + notes are reviewed and committed separately from semantic changes. +- [ ] Full build, Tier-P, Tier-NF, Tier-X, API, CLI, and clean-wheel tests pass + after each deletion checkpoint. + +## 7. Atomizer, SBML, and format capability union + +### 7.1 Playground-derived Atomizer + +- [ ] Reconcile the modern Python port against the pinned + RuleWorld/bngplayground source paths, preserving source-level provenance. +- [x] Source-derived modern tests cover the current annotation API, + BNG-XML conversion, Rulifier, UniProt seam/cache behavior, structure helpers, + conservative Multi discovery, and the public helper/rate-rule-constant + surface. +- [x] The public `utils/helpers` surface and + `writer/rateRuleConstants` values are ported at 53289e2 with + source-derived coverage in tests/python/test_modern_atomizer_helpers.py. +- [x] The selected public `atomization/core` facade helpers are ported at + 7de3194 with source-derived coverage in + tests/python/test_modern_atomizer_core.py; the remaining core and writer + surface is still open. +- [x] The public `bnglReaction`, `inlineSBMLFunctions`, and + `splitReversibleRate` writer facades are ported at 7e91acc with + source-derived coverage in tests/python/test_modern_atomizer_writer_facade.py + and tests/python/test_modern_atomizer_writer_rate_helpers.py. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts` at reference main + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` maps bare compartment IDs in + function definitions and assignment-rule bodies to emitted BNGL volume + parameters. BNG3 ports this bounded `mapCompartments` behavior at `5760be1` + with source-derived coverage in + `tests/python/test_modern_atomizer_writer_parameters.py`; the focused modern + Atomizer suite reports `77 passed`, and the full Python suite reports + `252 passed, 27 skipped, 8 warnings`. Broader writer, parser, SBML, and + independent round-trip parity remain open. +- [x] The same Playground writer reference stably topologically orders + assignment-rule functions before dependent rules, with cycles falling back + to source order. BNG3 ports this bounded behavior at `5adb545` with the + source-derived dependency-order contract in + `tests/python/test_modern_atomizer_writer_parameters.py`; the focused modern + Atomizer suite reports `78 passed`, and the full Python gate reports `253 + passed, 27 skipped, 8 warnings`. This does not close broader function, + parser, or independent SBML/BNGL parity. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts` at reference main + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` keeps time-only reaction rates + live by wrapping rates that contain `time()` but no species/observable + marker in generated zero-argument functions. The tests-first BNG3 port is + `f1eeebf91b2bcc976e0c2c49f54261bcfda9bcc5` in + `python/bionetgen/atomizer/modern/writer.py`; the red-first output was + `light: 0 -> M_B() 2 + time()`, and the repaired contract is + `tests/python/test_modern_atomizer.py::test_playground_writer_wraps_time_only_rates_in_live_functions`. + The focused modern Atomizer suite reports `79 passed`, the full Python gate + reports `254 passed, 27 skipped, 9 warnings`, and exact-head Release/Ninja + CTest reports `185/185`. This closes only the bounded time-rate writer + slice; broader writer/parser/SBML parity remains open. +- [x] Playground `src/lib/atomizer/parser/sbmlParser.ts:1331-1353,1650-1721,2374-2392` + at reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` coalesces duplicate + global parameter declarations when their values match, canonicalizes raw + parameter IDs with `standardizeName`, remaps conflicting IDs to the next + available suffix while emitting `SBM010`, and normalizes registered ID/name + aliases in extracted math with local-parameter precedence. Its local-parameter + loop also suffixes a duplicate canonical local ID using the source position + before registering raw-ID and name aliases under the suffixed ID. + The tests-first BNG3 checkpoints are `18e8d8fad1871fdda826c395af878ada5614382b` + and `871d261442be2f4808cbcabed51f6a017ba5488a` plus the parameter-ID + checkpoints `0810dc6c6e9fda597f3ced78119d95457631dbf0` and + `6871518f50a3d1cb6b66490d7ba31f3cfe6531ab` in + `python/bionetgen/atomizer/modern/parser.py`, with contracts in + `tests/python/test_modern_atomizer.py::test_playground_parser_disambiguates_duplicate_global_parameters`, + `tests/python/test_modern_atomizer.py::test_playground_parser_normalizes_duplicate_parameter_name_aliases_in_math`, + `tests/python/test_modern_atomizer.py::test_playground_parser_standardizes_parameter_ids_before_formula_aliasing`, + and `tests/python/test_modern_atomizer.py::test_playground_parser_suffixes_duplicate_local_parameter_ids`. + The alias red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k normalizes_duplicate` + reported `1 failed, 52 deselected`; the repaired duplicate-focused command + reports `2 passed, 51 deselected`. The parameter-ID red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k standardizes_parameter_ids` + reported `1 failed, 53 deselected`; the repaired combined command reports + `3 passed, 51 deselected`. The duplicate-local red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k suffixes_duplicate_local` + reported `1 failed, 54 deselected` with both emitted IDs equal to + `time_id`; the repaired command reports `1 passed, 54 deselected`. The + combined parser contract command reports `4 passed, 51 deselected`. The + modern Atomizer glob reports `105 passed`, the full Python gate reports + `280 passed, 27 skipped, 8 warnings` in `10.17s`, exact CTest reports + `190/190` in `1.39s`, Ruff passes, and + Black reports `186 files would be left unchanged`. This Python-only + checkpoint leaves the native `build/cpp/bng_cpp` artifact unchanged at + SHA-256 `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public PR/ref head readback is + `6871518f50a3d1cb6b66490d7ba31f3cfe6531ab`; hosted CI + [33700655331](https://github.com/RuleWorld/BNG3/actions/runs/33700655331), + CodeQL [33700655336](https://github.com/RuleWorld/BNG3/actions/runs/33700655336), + and formatting [33700655365](https://github.com/RuleWorld/BNG3/actions/runs/33700655365) + were queued at readback. This closes duplicate-ID recovery and formula + alias normalization plus canonical and duplicate-local parameter-ID recovery + for the modern parser; broader parser/SBML parity, output alias propagation, + and independent round-trip evidence remain open. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts:1025-1065` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` groups seed declarations + by canonical emitted pattern and fixedness, retains the first concentration + for each group, and maps every source species ID to that grouped pattern. + The tests-first BNG3 checkpoint is + `bb3c116aeebcccbc8a7f123b2cc62d563308f57e` in + `python/bionetgen/atomizer/modern/writer.py`, with the source-derived + contract in + `tests/python/test_modern_atomizer.py::test_playground_writer_groups_duplicate_seed_patterns`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k groups_duplicate_seed_patterns` + reported `1 failed, 55 deselected` because BNG3 emitted two identical seed + declarations; the repaired command reports `1 passed, 55 deselected`. The + contract also verifies that fixed and dynamic groups remain separate. The + modern Atomizer glob reports `106 passed`, the full Python gate reports + `281 passed, 27 skipped, 8 warnings` in `10.17s`, exact CTest reports + `190/190` in `1.44s`, changed-file Ruff passes, and changed-file Black + reports `2 files would be left unchanged`. This Python-only checkpoint + leaves the native `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public PR/ref head readback is + `bb3c116aeebcccbc8a7f123b2cc62d563308f57e`; hosted CodeQL + [33701338037](https://github.com/RuleWorld/BNG3/actions/runs/33701338037), + CI [33701338055](https://github.com/RuleWorld/BNG3/actions/runs/33701338055), + and formatting + [33701338204](https://github.com/RuleWorld/BNG3/actions/runs/33701338204) + were queued at readback. This closes only duplicate seed-pattern grouping; + broader writer/parser/SBML parity, fixed-seed provenance, and independent + round-trip evidence remain open. +- [x] Playground `src/lib/atomizer/core/structures.ts:337-360` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` builds a component-name `Map` + before `Molecule.extend`; when a molecule contains repeated component names, + the last existing component is the mapped target, while absent components are + deep-copied and registered. The tests-first BNG3 checkpoint is + `cd8d6faff52a188a3aff809bd2c7cd8bbab5bc14` in + `python/bionetgen/atomizer/modern/structures.py`, with the source-derived + contract in + `tests/python/test_modern_atomizer.py::test_playground_molecule_extend_updates_last_duplicate_component`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k updates_last_duplicate_component` + reported `1 failed, 56 deselected` because Python updated the first duplicate; + the repaired command reports `1 passed, 56 deselected`. The related structure + contract command reports `6 passed, 51 deselected`, the modern Atomizer glob + reports `107 passed`, the full Python gate reports `282 passed, 27 skipped, + 8 warnings` in `9.95s`, exact CTest reports `190/190` in `1.15s`, changed-file + Ruff passes, changed-file Black reports `2 files would be left unchanged`, + and `git diff --check` passes. This Python-only checkpoint leaves the native + `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch head readback is + `cd8d6faff52a188a3aff809bd2c7cd8bbab5bc14`; hosted CodeQL + [33701930187](https://github.com/RuleWorld/BNG3/actions/runs/33701930187), CI + [33701930243](https://github.com/RuleWorld/BNG3/actions/runs/33701930243), and + formatting [33701930296](https://github.com/RuleWorld/BNG3/actions/runs/33701930296) + were queued at readback. This closes only the duplicate-component `extend` + lookup slice; broader structure/core parity, repeated-site model semantics, + and independent round-trip evidence remain open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:271-330` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` returns modification inference as + a named `{base, modification, confidence}` result, including the empty + fallback result. The tests-first BNG3 checkpoint is + `33cbd138f5dca0b4a0804601948fcf3cca329631` in + `python/bionetgen/atomizer/modern/core.py`, with the facade export in + `python/bionetgen/atomizer/modern/__init__.py` and the source-derived + contract in + `tests/python/test_modern_atomizer_core.py::test_playground_infer_modification_returns_named_result`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_core.py -q -k infer_modification` + reported `1 failed, 8 deselected` because the BNG3 result was a bare tuple + without named fields. The repaired command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_core.py -q -k infer_modification` + reports `1 passed, 8 deselected` and verifies both named-field access and + backward-compatible tuple unpacking. The core-plus-rulifier command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_core.py tests/python/test_modern_atomizer_rulifier.py -q` + reports `15 passed`; the modern Atomizer glob + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer*.py -q` + reports `108 passed`; the full Python gate + `env PYTHONPATH=python:build/cpp python -m pytest -q` reports + `283 passed, 27 skipped, 8 warnings` in `12.51s`; exact CTest + `ctest --test-dir build --output-on-failure` reports `190/190` in `1.52s`; + `ruff check python/bionetgen/atomizer/modern/core.py python/bionetgen/atomizer/modern/__init__.py tests/python/test_modern_atomizer_core.py` + passes; `black --check python/bionetgen/atomizer/modern/core.py + python/bionetgen/atomizer/modern/__init__.py + tests/python/test_modern_atomizer_core.py` reports `3 files would be left + unchanged`; and `git diff --check` passes. This + Python-only checkpoint leaves the native `build/cpp/bng_cpp` artifact + unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch head readback is + `33cbd138f5dca0b4a0804601948fcf3cca329631`; hosted CI + [33702740615](https://github.com/RuleWorld/BNG3/actions/runs/33702740615), + CodeQL [33702740608](https://github.com/RuleWorld/BNG3/actions/runs/33702740608), + and formatting + [33702740639](https://github.com/RuleWorld/BNG3/actions/runs/33702740639) + were queued at readback. This closes only the named-result API slice; + broader atomization/core behavior, parser/writer parity, independent + round-trip evidence, and provenance remain open. +- [x] Playground `src/lib/atomizer/index.ts:502-512` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` re-exports the eight core + Atomizer APIs `buildSpeciesCompositionTable`, + `disambiguateCollidingSpecies`, `getMoleculeTypes`, `getSeedSpecies`, + `analyzeReactions`, `analyzeNamingConventions`, `topologicalSort`, and + `classifyReaction`. The tests-first BNG3 checkpoint is + `7afeb80c7969e93068ba19f8d030cf193fed5769` in + `python/bionetgen/atomizer/modern/core.py` and + `python/bionetgen/atomizer/modern/__init__.py`, with the source-derived + contract in + `tests/python/test_modern_atomizer_core.py::test_playground_facade_exports_camel_case_core_functions`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_core.py -q -k camel_case_core` + reported `1 failed, 9 deselected` because the camel-case exports were + absent. The repaired command reports `1 passed, 9 deselected`; the + core-plus-rulifier command reports `16 passed`; the modern Atomizer glob + reports `109 passed`; the full Python gate reports + `284 passed, 27 skipped, 8 warnings` in `10.36s`; exact CTest + `ctest --test-dir build --output-on-failure` reports `190/190` in `1.16s`; + changed-file Ruff passes; changed-file Black reports `3 files would be + left unchanged`; and `git diff --check` passes. This Python-only + checkpoint leaves the native `build/cpp/bng_cpp` artifact unchanged at + SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch head readback is + `7afeb80c7969e93068ba19f8d030cf193fed5769`; hosted CodeQL + [33703241274](https://github.com/RuleWorld/BNG3/actions/runs/33703241274), + formatting [33703241265](https://github.com/RuleWorld/BNG3/actions/runs/33703241265), + and CI [33703241255](https://github.com/RuleWorld/BNG3/actions/runs/33703241255) + were queued at readback. This closes only the public core-facade export + slice; deeper Atomizer behavior, parser/writer/SBML parity, independent + round-trip evidence, and provenance remain open. +- [x] Playground `src/lib/atomizer/index.ts:514-518` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` re-exports the existing writer + functions `generateBNGL`, `bnglFunction`, and `bnglReaction`. The + tests-first BNG3 checkpoint is + `503541c25575622313c12e05c08e53861f47d4f3` in + `python/bionetgen/atomizer/modern/writer.py` and + `python/bionetgen/atomizer/modern/__init__.py`, with the source-derived + contract in + `tests/python/test_modern_atomizer_writer_rate_helpers.py::test_playground_writer_facade_exports_reference_function_names`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_writer_rate_helpers.py -q -k writer_facade` + reported `1 failed, 4 deselected` because the reference spellings were + absent. The repaired command reports `1 passed, 4 deselected`; the writer + glob + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_writer*.py -q` + reports `15 passed`; the modern Atomizer glob + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer*.py -q` + reports `110 passed`; the full Python gate + `env PYTHONPATH=python:build/cpp python -m pytest -q` reports + `285 passed, 27 skipped, 8 warnings` in `10.27s`; exact CTest + `ctest --test-dir build --output-on-failure` reports `190/190` in `1.60s`; + `ruff check python/bionetgen/atomizer/modern/writer.py python/bionetgen/atomizer/modern/__init__.py tests/python/test_modern_atomizer_writer_rate_helpers.py` + passes; `black --check python/bionetgen/atomizer/modern/writer.py + python/bionetgen/atomizer/modern/__init__.py + tests/python/test_modern_atomizer_writer_rate_helpers.py` reports `3 files + would be left unchanged`; and `git diff --check` passes. This Python-only + checkpoint leaves the native `build/cpp/bng_cpp` artifact unchanged at + SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch head readback is + `503541c25575622313c12e05c08e53861f47d4f3`; hosted CodeQL + [33703490479](https://github.com/RuleWorld/BNG3/actions/runs/33703490479), + formatting [33703490602](https://github.com/RuleWorld/BNG3/actions/runs/33703490602), + and CI [33703490651](https://github.com/RuleWorld/BNG3/actions/runs/33703490651) + were queued at readback. This closes only the writer facade-name slice; + atomized/flat writer behavior, broader writer/parser/SBML parity, + independent round-trip evidence, and provenance remain open. +- [x] Playground `src/lib/atomizer/core/structures.ts:19-181,191-489,490-1015` + at reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` exposes the + camel-case Component, Molecule, and Species method surface used by the + Atomizer core. The tests-first BNG3 implementation checkpoint is + `0663b2effcaf8814fe9702f98c4690348655f810` in + `python/bionetgen/atomizer/modern/structures.py`; the follow-up public + cleanup checkpoint `220b1e2455968e12a987da777740c580f973a84e` removes + redundant Action/Rule/Databases aliases without changing the tested + structure surface. The source-derived contract is + `tests/python/test_modern_atomizer_structures.py::test_playground_structures_expose_camel_case_object_methods`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_structures.py -q -k camel_case_object` + reported `1 failed, 5 deselected` because `Component.addState` was absent; + the repaired full structures-file command reports `6 passed in 0.40s`. + The modern Atomizer glob reports `111 passed in 0.48s`; the full Python gate + reports `286 passed, 27 skipped, 8 warnings` in `10.46s`; exact CTest reports + `190/190` in `1.59s`; changed-file Ruff passes; changed-file Black reports + `2 files would be left unchanged`; and `git diff --check` passes. This + Python-only checkpoint leaves the native `build/cpp/bng_cpp` artifact + unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch head readback is + `220b1e2455968e12a987da777740c580f973a84e`; hosted formatting + [33704215080](https://github.com/RuleWorld/BNG3/actions/runs/33704215080), CI + [33704215073](https://github.com/RuleWorld/BNG3/actions/runs/33704215073), and + CodeQL [33704215158](https://github.com/RuleWorld/BNG3/actions/runs/33704215158) + were queued at readback. This closes only the tested public method-name + compatibility slice; broader structure behavior, repeated-site semantics, + parser/writer/SBML parity, and independent round-trip evidence remain open. +- [x] Playground `src/lib/atomizer/index.ts:64-453,468-500` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` exposes camel-case Atomizer + lifecycle/accessor methods (`setOptions`, `getOptions`, `flatTranslation`, + `fullAtomization`, `getModel`, `getSCT`, `getUniProtIds`, `getDatabases`, + `analyzeNaming`, and `analyzeReactionPatterns`) and the convenience + functions `sbmlToBngl`, `sbmlToBnglFlat`, and `sbmlToBnglAtomized`. The + tests-first BNG3 checkpoint is + `d58c2a6fb2ed2c478ef6a6152077bc21d0ad42bc` in + `python/bionetgen/atomizer/modern/__init__.py`, with the source-derived + contract in + `tests/python/test_modern_atomizer.py::test_playground_atomizer_facade_exposes_reference_method_names`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k atomizer_facade` + reported `1 failed, 57 deselected` because the reference facade names were + absent; the repaired command reports `1 passed, 57 deselected`. The modern + Atomizer glob reports `112 passed`; the full Python gate reports `287 passed, + 27 skipped, 8 warnings` in `10.24s`; exact CTest reports `190/190` in + `1.24s`; changed-file Ruff passes; changed-file Black reports `2 files would + be left unchanged`; and `git diff --check` passes. This Python-only + checkpoint leaves the native `build/cpp/bng_cpp` artifact unchanged at + SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch head readback is + `d58c2a6fb2ed2c478ef6a6152077bc21d0ad42bc`; hosted CodeQL + [33704586351](https://github.com/RuleWorld/BNG3/actions/runs/33704586351), CI + [33704586367](https://github.com/RuleWorld/BNG3/actions/runs/33704586367), and + formatting + [33704586407](https://github.com/RuleWorld/BNG3/actions/runs/33704586407) + were queued at readback. This closes only the tested facade-name slice; the + TypeScript async lifecycle, Databases/result object shapes, broader Atomizer + behavior, parser/writer/SBML parity, and independent round-trip evidence + remain open. +- [x] Playground `src/lib/atomizer/writer/bnglWriter.ts:2114-2857` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` returns the named + `BNGLGenerationResult` fields `bngl`, `observableMap`, and `warnings`. The + tests-first BNG3 checkpoint is + `5afc84ecbb558092fca9ff2528655c175b574707` in + `python/bionetgen/atomizer/modern/writer.py`, with the facade export in + `python/bionetgen/atomizer/modern/__init__.py` and the source-derived + contract in + `tests/python/test_modern_atomizer_writer_rate_helpers.py::test_playground_generate_bngl_returns_named_generation_result`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_writer_rate_helpers.py -q -k named_generation` + reported `1 failed, 5 deselected` because `generate_bngl` returned a bare + tuple; the repaired command reports `1 passed, 5 deselected` and verifies + named fields, the camel-case map alias, warnings, and legacy two-value + unpacking. The writer glob reports `16 passed`; the modern Atomizer glob + reports `113 passed`; the full Python gate reports `288 passed, 27 skipped, + 8 warnings` in `10.19s`; exact CTest reports `190/190` in `1.62s`; + changed-file Ruff passes; changed-file Black reports `3 files would be left + unchanged`; and `git diff --check` passes. This Python-only checkpoint + leaves the native `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch head readback is + `5afc84ecbb558092fca9ff2528655c175b574707`; hosted formatting + [33704892921](https://github.com/RuleWorld/BNG3/actions/runs/33704892921), CI + [33704892945](https://github.com/RuleWorld/BNG3/actions/runs/33704892945), and + CodeQL [33704892981](https://github.com/RuleWorld/BNG3/actions/runs/33704892981) + were queued at readback. This closes only the named generation-result and + backward-compatible unpacking slice; warning-production parity, atomized and + flat writer behavior, broader parser/SBML parity, and independent + round-trip evidence remain open. +- [x] Playground `src/lib/atomizer/config/types.ts:460-690` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` exposes camel-case SBML, + species-composition-table, seed-species, and result fields, including + `spatialDimensions`, `initialConcentration`, `initialAmount`, + `hasOnlySubstanceUnits`, `stoichiometrySet`, `mathML`, `localParameters`, + `kineticLaw`, `useValuesFromTriggerTime`, `functionDefinitions`, + `speciesByCompartment`, `importWarnings`, `sbmlId`, `isElemental`, + `reverseDependencies`, `sortedSpecies`, and `observableMap`. The + tests-first BNG3 checkpoint is + `a2a35118bb6fa15cb772b4dfd02459dfae5d7190` in + `python/bionetgen/atomizer/modern/types.py`, with the source-derived + contract in + `tests/python/test_modern_atomizer_core.py::test_playground_data_contract_exposes_camel_case_fields`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_core.py -q -k data_contract` + reported `1 failed, 10 deselected` because `spatialDimensions` was absent; + the repaired command reports `1 passed, 10 deselected`. The core file + reports `11 passed`, the modern Atomizer glob reports `114 passed`, and the + full Python gate reports `289 passed, 27 skipped, 8 warnings` in `15.63s`. + Exact CTest reports `190/190` in `2.54s`; changed-file Ruff and Black pass; + and `git diff --check` passes. This Python-only checkpoint leaves the native + `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `a2a35118bb6fa15cb772b4dfd02459dfae5d7190`; the hosted checks listed at + the top remain queued and are not completion evidence. This closes only the + bounded data-field naming/mutability slice; broader source/Python result + shapes, parser/writer/SBML behavior, and independent round-trip evidence + remain open. +- [x] Playground `src/lib/atomizer/config/types.ts:481-546,643-651` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` defines the + `compartmentType` field, `SBMLModifierSpeciesReference`, and structured + `SBMLImportWarning` (`category`, `message`, `count`, and `severity`) record + contracts. The tests-first BNG3 checkpoint is + `667935b327409f07f74bb81fc7066b8295947b95` in + `python/bionetgen/atomizer/modern/types.py`, with the source-derived + contract in + `tests/python/test_modern_atomizer_core.py::test_playground_data_contract_exposes_missing_sbml_record_types`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_core.py -q -k missing_sbml_record_types` + reported `1 failed, 11 deselected` because `SBMLImportWarning` was absent; + the repaired command reports `1 passed, 11 deselected`. The core file + reports `12 passed`, the modern Atomizer glob reports `115 passed`, and the + full Python gate reports `290 passed, 27 skipped, 8 warnings` in `14.85s`. + Exact CTest reports `190/190` in `1.97s`; changed-file Ruff and Black pass; + and `git diff --check` passes. This Python-only checkpoint leaves the native + `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `667935b327409f07f74bb81fc7066b8295947b95`; hosted CI + [33705751711](https://github.com/RuleWorld/BNG3/actions/runs/33705751711), + CodeQL [33705751714](https://github.com/RuleWorld/BNG3/actions/runs/33705751714), + and formatting [33705751719](https://github.com/RuleWorld/BNG3/actions/runs/33705751719) + remain queued and are not completion evidence. This closes only the + bounded record-type contract; parser warning instances remain dictionary + shaped, modifier/reaction behavior remains broader work, and complete + parser/writer/SBML parity and independent round-trip evidence remain open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:376-390` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` maps each + `SBMLModifierSpeciesReference` through its `species` field before returning + a reaction pattern. The tests-first BNG3 checkpoint is + `b9df8db49d574ccb5fc1f35bddf649073bd975ef` in + `python/bionetgen/atomizer/modern/core.py` and + `python/bionetgen/atomizer/modern/types.py`, with the source-derived + contract in + `tests/python/test_modern_atomizer_core.py::test_playground_reaction_classification_normalizes_modifier_records`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_core.py -q -k normalizes_modifier_records` + reported `1 failed, 12 deselected` because the record object leaked into + `ReactionPattern.modifiers`; the repaired command reports `1 passed, 12 + deselected`. The core file reports `13 passed`, the modern Atomizer glob + reports `116 passed`, and the full Python gate reports `291 passed, 27 + skipped, 8 warnings` in `15.30s`. Exact CTest reports `190/190` in `1.92s`; + changed-file Ruff and Black pass; and `git diff --check` passes. This + Python-only checkpoint leaves the native `build/cpp/bng_cpp` artifact + unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `b9df8db49d574ccb5fc1f35bddf649073bd975ef`; hosted CI + [33706954325](https://github.com/RuleWorld/BNG3/actions/runs/33706954325), + CodeQL [33706954333](https://github.com/RuleWorld/BNG3/actions/runs/33706954333), + and formatting [33706954323](https://github.com/RuleWorld/BNG3/actions/runs/33706954323) + remain queued and are not completion evidence. This closes only object + modifier normalization in reaction classification; constructors and + downstream classification retain legacy string compatibility while the XML + parser now emits source-shaped records, and broader reaction/parser, + writer/SBML, and independent round-trip parity remain open. +- [x] Playground `src/lib/atomizer/writer/eventActions.ts:30-44,138-158,178-207` + at reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` exposes the + `EventSet`, `EventTranslationContext`, and `EventActionsResult` contracts + plus the `foldNumeric` and `parseTimeThreshold` helper spellings. The + tests-first BNG3 checkpoint is + `43afe69b83b9d994b9bb103dfad304e0e3b4509c` in + `python/bionetgen/atomizer/modern/events.py` and + `python/bionetgen/atomizer/modern/__init__.py`, with the source-derived + contract in + `tests/python/test_modern_atomizer_events.py::test_playground_event_actions_exposes_reference_names_and_result_fields`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_events.py -q` + reported `1 failed` because `EventActionsResult` was absent; the repaired + command reports `1 passed`. The existing event behavior contract reports + `3 passed, 55 deselected`; the modern Atomizer glob reports `117 passed`; + the full Python gate reports `292 passed, 27 skipped, 8 warnings` in + `14.91s`; exact CTest reports `190/190` in `1.89s`; changed-file Ruff and + Black pass; and `git diff --check` passes. This Python-only checkpoint + leaves the native `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `43afe69b83b9d994b9bb103dfad304e0e3b4509c`; hosted CI + [33707224496](https://github.com/RuleWorld/BNG3/actions/runs/33707224496), + CodeQL [33707224180](https://github.com/RuleWorld/BNG3/actions/runs/33707224180), + and formatting [33707224186](https://github.com/RuleWorld/BNG3/actions/runs/33707224186) + remain queued and are not completion evidence. This closes only the + bounded reference names and writable context/result aliases; Python keeps + tuple-shaped untranslated diagnostics and existing scheduling internals, + while complete event semantics and broader writer/SBML parity remain open. +- [x] Playground `src/lib/atomizer/core/structures.ts:143-151` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` exposes `Component.toString()` as + the rule-style string representation. The tests-first BNG3 checkpoint is + `99e12f5920bbb5d830fee08bd9fa896dffc3cecd` in + `python/bionetgen/atomizer/modern/structures.py`, with the source-derived + assertion in + `tests/python/test_modern_atomizer_structures.py::test_playground_structures_expose_camel_case_object_methods`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_structures.py -q -k camel_case_object` + reported `1 failed, 5 deselected` because the alias was absent; the repaired + command reports `1 passed, 5 deselected`. The structure file reports `6 + passed`; the modern Atomizer glob reports `117 passed`; the full Python gate + reports `292 passed, 27 skipped, 8 warnings` in `14.67s`; exact CTest reports + `190/190` in `1.89s`; changed-file Ruff and Black pass; and + `git diff --check` passes. This Python-only checkpoint leaves the native + `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `99e12f5920bbb5d830fee08bd9fa896dffc3cecd`; hosted CI + [33707406164](https://github.com/RuleWorld/BNG3/actions/runs/33707406164), + CodeQL [33707406204](https://github.com/RuleWorld/BNG3/actions/runs/33707406204), + and formatting [33707406092](https://github.com/RuleWorld/BNG3/actions/runs/33707406092) + remain queued and are not completion evidence. This closes only the + remaining tested Component method-name alias; broader structure behavior, + repeated-site semantics, parser/writer/SBML parity, and independent + round-trip evidence remain open. +- [x] Playground `src/lib/atomizer/core/structures.ts:1179-1205` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` exports the + `readFromString` BNGL-pattern parser helper. The tests-first BNG3 checkpoint + is `4c84b37c302e4a4ec9d59cf40c106729b8085e97` in + `python/bionetgen/atomizer/modern/structures.py` and + `python/bionetgen/atomizer/modern/__init__.py`, with the source-derived + assertion in + `tests/python/test_modern_atomizer_structures.py::test_playground_structures_expose_camel_case_object_methods`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_structures.py -q -k camel_case_object` + reported `1 failed, 5 deselected` because the facade alias was absent; the + repaired command reports `1 passed, 5 deselected`. The structure file + reports `6 passed`; the modern Atomizer glob reports `117 passed`; the full + Python gate reports `292 passed, 27 skipped, 8 warnings` in `14.88s`; exact + CTest reports `190/190` in `1.93s`; changed-file Ruff and Black pass; and + `git diff --check` passes. This Python-only checkpoint leaves the native + `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `4c84b37c302e4a4ec9d59cf40c106729b8085e97`; hosted CI + [33707604898](https://github.com/RuleWorld/BNG3/actions/runs/33707604898), + CodeQL [33707604817](https://github.com/RuleWorld/BNG3/actions/runs/33707604817), + and formatting [33707604823](https://github.com/RuleWorld/BNG3/actions/runs/33707604823) + remain queued and are not completion evidence. This closes only the + source-compatible helper spelling; broader parser semantics, structure + behavior, writer/SBML parity, and independent round-trip evidence remain + open. +- [x] Playground `src/lib/atomizer/parser/sbmlParser.ts:1559-1570` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` preserves an SBML + compartment's `compartmentType` reference when extracting the model. The + tests-first BNG3 checkpoint is + `e25766e2867adf07925bdedf0244b370f9f43b95` in + `python/bionetgen/atomizer/modern/parser.py`, with the source-derived XML + contract in + `tests/python/test_modern_atomizer.py::test_playground_parser_preserves_compartment_type_reference`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k preserves_compartment_type_reference` + reported `1 failed, 58 deselected` because the parsed field was `None`; the + repaired command reports `1 passed, 58 deselected`. The parser subset reports + `14 passed, 46 deselected`; the modern Atomizer glob reports `118 passed`; + the full Python gate reports `293 passed, 27 skipped, 8 warnings` in + `15.04s`; exact CTest reports `190/190` in `2.08s`; changed-file Ruff and + Black pass; and `git diff --check` passes. This Python-only checkpoint + leaves the native `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `e25766e2867adf07925bdedf0244b370f9f43b95`; hosted CI + [33707794248](https://github.com/RuleWorld/BNG3/actions/runs/33707794248), + CodeQL [33707794247](https://github.com/RuleWorld/BNG3/actions/runs/33707794247), + and formatting [33707794244](https://github.com/RuleWorld/BNG3/actions/runs/33707794244) + remain queued and are not completion evidence. This closes only the + compartment-type extraction slice; broader SBML parser behavior, writer + parity, and independent round-trip evidence remain open. +- [x] Playground `src/lib/atomizer/parser/sbmlParser.ts:2339-2346` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` extracts reaction + modifiers as `SBMLModifierSpeciesReference` records. The tests-first BNG3 + checkpoint is `753e57b42b9562ca1f2b6e06c948579d6e4f0b82` in + `python/bionetgen/atomizer/modern/parser.py`, with the source-derived XML + contract in + `tests/python/test_modern_atomizer.py::test_playground_parser_preserves_modifier_species_records`. + The red-first command + `env PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k preserves_modifier_species_records` + reported `1 failed, 59 deselected` because the parser returned a bare string; + the repaired command reports `1 passed, 59 deselected`. The parser subset + reports `14 passed, 46 deselected`; the modern Atomizer glob reports `119 + passed`; the full Python gate reports `294 passed, 27 skipped, 8 warnings` + in `15.04s`; exact CTest reports `190/190` in `2.01s`; changed-file Ruff + and Black pass; and `git diff --check` passes. This Python-only checkpoint + leaves the native `build/cpp/bng_cpp` artifact unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `753e57b42b9562ca1f2b6e06c948579d6e4f0b82`; hosted CI + [33707948107](https://github.com/RuleWorld/BNG3/actions/runs/33707948107), + CodeQL [33707948104](https://github.com/RuleWorld/BNG3/actions/runs/33707948104), + and formatting [33707948098](https://github.com/RuleWorld/BNG3/actions/runs/33707948098) + remain queued and are not completion evidence. This closes only XML parser + modifier-record extraction; constructor compatibility is retained, while + broader reaction/parser, writer/SBML, and independent round-trip parity + remain open. +- [x] Playground `src/lib/atomizer/config/types.ts:640-651` and + `src/lib/atomizer/parser/sbmlParser.ts:803-809,1517-1519` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` represent `importWarnings` as + structured records with `category`, `message`, `count`, and the + `dropped`/`approximated`/`info` severity contract. The tests-first BNG3 + checkpoint is `be9d6a25a02f20bad7182b9ea8409c37fdc137e9` in + `python/bionetgen/atomizer/modern/types.py`, `parser.py`, and `writer.py`; + `SBMLImportWarning` exposes the source-shaped attributes and a mapping view + so existing dictionary consumers remain compatible, while parser and writer + paths normalize legacy dictionaries at their model boundary. The red-first + command + `PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer_core.py -q -k structured_import_warning_records` + reported `1 failed, 13 deselected`; the repaired command reports `1 passed, + 13 deselected`. The focused modern-core/parser regression command reports + `74 passed`; the modern Atomizer glob reports `120 passed`; the full Python + gate reports `295 passed, 27 skipped, 8 warnings` in `15.97s`; the exact + Release/Ninja CTest gate reports `190/190` in `2.31s`; the export gate reports + `12 passed`; Ruff/Black and `git diff --check` pass. The native + `build/cpp/bng_cpp` artifact remains unchanged at SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `be9d6a25a02f20bad7182b9ea8409c37fdc137e9`; hosted CI + [33708860933](https://github.com/RuleWorld/BNG3/actions/runs/33708860933), + CodeQL [33708860926](https://github.com/RuleWorld/BNG3/actions/runs/33708860926), + and formatting + [33708860908](https://github.com/RuleWorld/BNG3/actions/runs/33708860908) + remain queued and are not completion evidence. This closes only the + structured-warning data contract; complete parser diagnostics, writer/SBML + parity, independent round trips, and release validation remain open. +- [x] Playground `src/lib/atomizer/config/types.ts:579-594` and + `src/lib/atomizer/parser/sbmlParser.ts:2645-2682` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` represent event assignments as + structured `{variable, math}` records. The tests-first BNG3 checkpoint is + `eefdf3b9fb147b1d0558b7b213ecd315f98017cb` in + `python/bionetgen/atomizer/modern/types.py`, `events.py`, and `writer.py`; + `SBMLEventAssignment` exposes source-shaped attributes, string-key mapping + access, and legacy tuple equality while event translation and diagnostics + accept the structured record. The red-first command + `PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k source_shaped_event_assignment_records` + reported `1 failed, 60 deselected` because the public record type was absent; + the repaired event/record command reports `4 passed, 57 deselected`. The + modern regression command reports `76 passed`; the modern Atomizer glob + reports `121 passed`; the full Python gate reports `296 passed, 27 skipped, + 8 warnings` in `15.30s`; exact Release/Ninja CTest reports `190/190` in + `1.81s`; and export validation reports `12 passed` in `18.54s`. Changed-file + Ruff and Black pass (`4 files would be left unchanged`), and `git diff + --check` passes. The native `build/cpp/bng_cpp` artifact remains SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `eefdf3b9fb147b1d0558b7b213ecd315f98017cb`; hosted CI + [33712428796](https://github.com/RuleWorld/BNG3/actions/runs/33712428796), + CodeQL [33712428667](https://github.com/RuleWorld/BNG3/actions/runs/33712428667), + and formatting + [33712428660](https://github.com/RuleWorld/BNG3/actions/runs/33712428660) + remain pending and are not completion evidence. This closes only the + source-shaped event-assignment data contract; broader event semantics, + parser/writer/SBML parity, independent round trips, and release validation + remain open. +- [x] Playground `src/lib/atomizer/utils/helpers.ts:608-616,645-721` and + `src/lib/atomizer/index.ts:99-205` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` return structured `LogMessage` + records in every successful or failed `AtomizerResult.log`. The tests-first + BNG3 checkpoint is `4be16b1b904087b3db0f01cd5ab2c801d7c5ddee` in + `python/bionetgen/atomizer/modern/__init__.py`, `types.py`, and + `tests/python/test_modern_atomizer.py`; the existing logger records are now + returned instead of summary strings on both fast and normal paths, including + failure diagnostics. The red-first command + `PYTHONPATH=python:build/cpp python -m pytest tests/python/test_modern_atomizer.py -q -k result_returns_structured_log_records` + reported `1 failed, 61 deselected, 2 warnings`; the repaired command with + `-p no:cacheprovider` reports `1 passed, 61 deselected`. The focused + lifecycle/fast-path/facade command reports `3 passed`; the modern Atomizer + glob reports `122 passed`; the full Python gate reports `297 passed, 27 + skipped, 8 warnings` in `15.97s`; exact Release/Ninja CTest reports `190/190` + in `1.93s`; and export validation reports `12 passed` in `18.71s`. Changed- + file Ruff (`--no-cache`) passes, Black reports `3 files would be left + unchanged`, and `git diff --check` passes. The native + `build/cpp/bng_cpp` artifact remains SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `4be16b1b904087b3db0f01cd5ab2c801d7c5ddee`; hosted CI + [33712957959](https://github.com/RuleWorld/BNG3/actions/runs/33712957959), + CodeQL [33712957955](https://github.com/RuleWorld/BNG3/actions/runs/33712957955), + and formatting + [33712957961](https://github.com/RuleWorld/BNG3/actions/runs/33712957961) + remain pending and are not completion evidence. This closes only structured + result-log records; the source asynchronous `initialize()` lifecycle has no + direct synchronous Python equivalent, and broader parser/writer/SBML parity, + independent round trips, and release validation remain open. +- [x] Playground `src/lib/atomizer/core/structures.ts:1113-1152` and + `src/lib/atomizer/index.ts:43-59,129-132,184-199,375-380,453-457` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` construct and return a + `Databases` container from the Atomizer accessor, result, and `clear()` lifecycle. + The tests-first BNG3 checkpoint is + `fc65e25292927e7c3a8e02d684cda63f5921738f` in + `python/bionetgen/atomizer/modern/__init__.py` and + `tests/python/test_modern_atomizer.py`; the prior dictionary substitution is + removed from both fast and normal paths, and the result/accessor now preserve + the same `Databases` object. The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer.py -q -k returns_databases_container` + reported `1 failed, 62 deselected`; the repaired focused command reports + `4 passed, 59 deselected`. The modern Atomizer glob reports `123 passed`; the + full Python gate reports `298 passed, 27 skipped, 8 warnings` in `14.98s`; + exact Release/Ninja CTest reports `190/190` in `1.64s`; and export validation + reports `12 passed` in `18.45s`. Changed-file Ruff (`--no-cache`) passes, + Black reports `2 files would be left unchanged`, and `git diff --check` + passes. The native `build/cpp/bng_cpp` artifact remains SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `fc65e25292927e7c3a8e02d684cda63f5921738f`; hosted CI + [33713386928](https://github.com/RuleWorld/BNG3/actions/runs/33713386928), + CodeQL [33713386951](https://github.com/RuleWorld/BNG3/actions/runs/33713386951), + and formatting + [33713386952](https://github.com/RuleWorld/BNG3/actions/runs/33713386952) + remain queued and are not completion evidence. This closes the result + container shape only; the source TypeScript `Map` containers are represented + by existing Python mapping fields, and broader parser/writer/SBML parity, + independent round trips, direct-NFsim, and release validation remain open. +- [x] Playground `src/lib/atomizer/validation/multiPackage.ts:30-41,78-91,167-176,226-242` + at reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` types every + `MultiParseResult.warnings` entry as an `SBMLImportWarning` record. The + tests-first BNG3 checkpoint is + `c52cc97986dd2fd09d44a0d276e54a92596af162` in + `python/bionetgen/atomizer/modern/multi.py` and + `tests/python/test_modern_atomizer_multi.py`; direct Multi parsing now + returns the structured record while its mapping view preserves existing + dictionary-style consumers. The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer_multi.py -q -k structured_warning` + reported `1 failed, 2 deselected`; the repaired full Multi-file command + reports `3 passed`. The modern Atomizer glob reports `124 passed`; the full + Python gate reports `299 passed, 27 skipped, 8 warnings` in `15.59s`; + exact Release/Ninja CTest reports `190/190` in `1.77s`; and export validation + reports `12 passed` in `19.49s`. Changed-file Ruff (`--no-cache`) passes, + Black reports `2 files would be left unchanged`, and `git diff --check` + passes. The native `build/cpp/bng_cpp` artifact remains SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `c52cc97986dd2fd09d44a0d276e54a92596af162`; hosted CI + [33713756152](https://github.com/RuleWorld/BNG3/actions/runs/33713756152), + CodeQL [33713756197](https://github.com/RuleWorld/BNG3/actions/runs/33713756197), + and formatting + [33713756179](https://github.com/RuleWorld/BNG3/actions/runs/33713756179) + remain queued and are not completion evidence. This closes the direct + Multi warning-record shape only; canonical/deep Multi structures remain + commented diagnostics and are not injected into the simulated network, + with full Multi reconstruction, writer/schema, execution, and broader + parser/SBML parity still open. +- [x] Playground `src/lib/atomizer/validation/multiPackage.ts:30-41,179-242` + at reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` exposes camel-case + `MultiParseResult` fields and object-shaped `complexPatterns` entries with + `typeId` and `pattern`. The tests-first BNG3 checkpoint is + `0b611ec6b72e1390ff1b48f923ae756574e3afbc` in + `python/bionetgen/atomizer/modern/multi.py` and + `tests/python/test_modern_atomizer_multi.py`; Python now returns the + source-shaped record while retaining tuple unpacking for existing parser + consumers. The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer_multi.py -q -k reference_result` + reported `1 failed, 3 deselected`; the repaired full Multi-file command + reports `4 passed`. The modern Atomizer glob reports `125 passed`; the full + Python gate reports `300 passed, 27 skipped, 8 warnings` in `14.95s`; + exact Release/Ninja CTest reports `190/190` in `1.57s`; and export validation + reports `12 passed` in `18.60s`. Changed-file Ruff (`--no-cache`) passes, + Black reports `2 files would be left unchanged`, and `git diff --check` + passes. The native `build/cpp/bng_cpp` artifact remains SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `0b611ec6b72e1390ff1b48f923ae756574e3afbc`; hosted CI + [33714138583](https://github.com/RuleWorld/BNG3/actions/runs/33714138583), + CodeQL [33714138588](https://github.com/RuleWorld/BNG3/actions/runs/33714138588), + and formatting + [33714138602](https://github.com/RuleWorld/BNG3/actions/runs/33714138602) + remain queued and are not completion evidence. This closes only the direct + Multi result-record shape; seed patterns remain unconstructed, extracted + structures remain commented diagnostics, full Multi writer/schema and + simulation validation remain open. +- [x] Playground `src/lib/atomizer/parser/sbmlParser.ts:1963-1989,2083-2109` + at reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` records explicit, + deduplicated diagnostics for lossy `/`, ``, + ``, ``, ``, and `` MathML constructs. The + tests-first BNG3 checkpoint is + `6ff6911d643a02d3ee9d064c762471fe50d9972f` in + `python/bionetgen/atomizer/modern/parser.py` and + `tests/python/test_modern_atomizer.py`. The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer.py -q -k lossy_mathml_constants_and_operators` + reported `1 failed, 63 deselected in 0.52s`; the repaired focused command + reports `1 passed, 63 deselected in 0.40s`. The modern Atomizer glob reports + `126 passed in 0.58s`; the full Python gate reports + `301 passed, 27 skipped, 8 warnings` in `14.85s`; exact Release/Ninja CTest + reports `190/190` in `1.46s`; and export validation reports `12 passed` in + `18.29s`. Changed-file Ruff (`--no-cache`) passes, Black with the configured + `--target-version py312` reports `2 files would be left unchanged`, and + `git diff --check` passes. The native `build/cpp/bng_cpp` artifact remains + SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `6ff6911d643a02d3ee9d064c762471fe50d9972f`; hosted CI + [33715166102](https://github.com/RuleWorld/BNG3/actions/runs/33715166102), + CodeQL [33715166047](https://github.com/RuleWorld/BNG3/actions/runs/33715166047), + and formatting + [33715166057](https://github.com/RuleWorld/BNG3/actions/runs/33715166057) + remain queued and are not completion evidence. This closes only the + explicit diagnostics slice; complete MathML translation, schema and + round-trip coverage, remaining parser/writer/SBML parity, SBML-Multi, + direct-NFsim, legacy, packaging, release, and hosted validation gaps remain + open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:1129-1160` and + `src/lib/atomizer/index.ts:22-30` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` export the source-shaped + `reconcileSCT` facade for filling missing molecule components from finalized + molecule types. BNG3 already carried the matching snake-case implementation; + the tests-first compatibility checkpoint is + `dd7c0dec44278bf62b03497089ad0e777bf482a6` in + `python/bionetgen/atomizer/modern/core.py`, + `python/bionetgen/atomizer/modern/__init__.py`, and + `tests/python/test_modern_atomizer_core.py`. The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer_core.py -q -k camel_case_core` + reported `1 failed, 13 deselected in 0.55s`; the repaired command reports + `1 passed, 13 deselected in 0.37s`. The modern Atomizer glob reports + `126 passed in 0.59s`; the full Python gate reports + `301 passed, 27 skipped, 8 warnings` in `15.58s`; exact Release/Ninja CTest + reports `190/190` in `1.57s`. Changed-file Ruff (`--no-cache`) passes, Black + with the configured `--target-version py312` reports `3 files would be left + unchanged`, and `git diff --check` passes. The native + `build/cpp/bng_cpp` artifact remains SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `dd7c0dec44278bf62b03497089ad0e777bf482a6`; hosted CI + [33715585114](https://github.com/RuleWorld/BNG3/actions/runs/33715585114), + CodeQL [33715585019](https://github.com/RuleWorld/BNG3/actions/runs/33715585019), + and formatting + [33715585115](https://github.com/RuleWorld/BNG3/actions/runs/33715585115) + remain queued and are not completion evidence. This closes only the + source-compatible facade name; deeper core behavior, parser/writer/SBML + parity, independent round trips, SBML-Multi, direct-NFsim, and release + validation remain open. +- [x] Playground `src/lib/atomizer/parser/sbmlParser.ts:2293-2302` at + reference `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` preserves L2 rational + species-reference stoichiometry by dividing the parsed numerator by a finite, + nonzero `denominator` attribute. The tests-first BNG3 checkpoint is + `7d729a041a4aba6eacb8f23c6b0e0af262379ee9` in + `python/bionetgen/atomizer/modern/parser.py` and + `tests/python/test_modern_atomizer.py`. The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer.py -q -k preserves_l2_rational_stoichiometry` + reported `1 failed, 64 deselected in 0.66s`; the repaired focused command + reports `1 passed, 64 deselected in 0.40s`. The modern Atomizer glob reports + `127 passed in 0.57s`; the full Python gate reports + `302 passed, 27 skipped, 8 warnings` in `14.89s`; exact Release/Ninja CTest + reports `190/190` in `1.63s`. Changed-file Ruff (`--no-cache`) passes, Black + with the configured `--target-version py312` reports `2 files would be left + unchanged`, and `git diff --check` passes. The native + `build/cpp/bng_cpp` artifact remains SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `7d729a041a4aba6eacb8f23c6b0e0af262379ee9`; hosted CI + [33716014298](https://github.com/RuleWorld/BNG3/actions/runs/33716014298), + CodeQL [33716014300](https://github.com/RuleWorld/BNG3/actions/runs/33716014300), + and formatting + [33716014299](https://github.com/RuleWorld/BNG3/actions/runs/33716014299) + remain queued and are not completion evidence. This closes only L2 rational + denominator preservation; broader stoichiometry, parser/writer/SBML, + independent round-trip, SBML-Multi, direct-NFsim, and release validation + gaps remain open. +- [x] Playground `src/lib/atomizer/config/types.ts:325-405` and + `src/lib/atomizer/index.ts:43-91` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` names the supported constructor + and `setOptions` fields `useId`, `quietMode`, `logLevel`, `tEnd`, and + `nSteps`. BNG3 normalizes those source spellings to its existing snake-case + option mapping at `beb0cee6424e94b64d6014febde355e30908299f` in + `python/bionetgen/atomizer/modern/__init__.py`; other source + `AtomizerOptions` fields remain explicitly outside this bounded port. The + tests-first contract is + `tests/python/test_modern_atomizer.py::test_playground_atomizer_accepts_supported_source_option_spellings`. + The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer.py -q -k accepts_supported_source_option_spellings` + reported `1 failed, 65 deselected in 0.58s`; the repaired focused command + reports `1 passed, 65 deselected in 0.40s`. The modern Atomizer glob reports + `128 passed in 0.55s`; the full Python gate reports `303 passed, 27 skipped, + 8 warnings` in `16.14s`; exact Release/Ninja CTest reports `190/190` in + `1.70s`. Changed-file Ruff (`--no-cache`) passes, Black with the configured + `--target-version py312` reports `2 files would be left unchanged`, and + `git diff --check` passes. The native `build/cpp/bng_cpp` artifact remains + SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `beb0cee6424e94b64d6014febde355e30908299f`; hosted CI + [33716598867](https://github.com/RuleWorld/BNG3/actions/runs/33716598867), + CodeQL [33716598872](https://github.com/RuleWorld/BNG3/actions/runs/33716598872), + and formatting + [33716598870](https://github.com/RuleWorld/BNG3/actions/runs/33716598870) + remain queued and are not completion evidence. This closes only the source + option spelling boundary; broader Atomizer behavior, parser/writer/SBML + parity, independent round trips, SBML-Multi, direct-NFsim, legacy, + packaging, release, and hosted validation gaps remain open. +- [x] Playground `src/lib/atomizer/config/types.ts:154-228` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` classifies the documented long + modification, binding, localization, dimerization, and trimerization name + patterns in `DEFAULT_NAMING_CONVENTIONS.patterns`. BNG3 ports the complete + pattern-map slice at `f64f46791db06e4204ed49e5a33cb9e923fde3cc` in + `python/bionetgen/atomizer/modern/types.py`, preserving the existing + tuple-key Python representation. The tests-first contract is + `tests/python/test_modern_atomizer_core.py::test_playground_default_naming_patterns_cover_source_words`. + The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer_core.py -q -k default_naming_patterns_cover_source_words` + reported `13 failed, 14 deselected in 0.53s`; the repaired focused command + reports `13 passed, 14 deselected in 0.35s`. The complete core test file + reports `27 passed in 0.36s`; the modern Atomizer glob reports `141 passed in + 0.60s`; the full Python gate reports `316 passed, 27 skipped, 8 warnings` + in `15.38s`; exact Release/Ninja CTest reports `190/190` in `1.72s`. + Changed-file Ruff (`--no-cache`) passes, Black with the configured + `--target-version py312` reports `2 files would be left unchanged`, and + `git diff --check` passes. The native `build/cpp/bng_cpp` artifact remains + SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `f64f46791db06e4204ed49e5a33cb9e923fde3cc`; hosted CI + [33716982357](https://github.com/RuleWorld/BNG3/actions/runs/33716982357), + CodeQL [33716982281](https://github.com/RuleWorld/BNG3/actions/runs/33716982281), + and formatting + [33716982433](https://github.com/RuleWorld/BNG3/actions/runs/33716982433) + remain queued and are not completion evidence. This closes only the default + pattern-map slice; the richer TypeScript naming-convention configuration, + broader Atomizer behavior, parser/writer/SBML parity, independent round + trips, SBML-Multi, direct-NFsim, legacy, packaging, release, and hosted + validation gaps remain open. +- [x] Playground `src/lib/atomizer/atomization/core.ts:227-265` at reference + `1914b8ccc8c2d4da2b1c1bb2b90b2bfc98224f6c` serializes each distinct naming + difference list as a compact JSON string in the `analyzeNamingConventions` + result's `keys` field. BNG3 matches that result shape at + `655810d5e2ea65dc25204f234333a30f170229b1` in + `python/bionetgen/atomizer/modern/core.py`. The tests-first contract is + `tests/python/test_modern_atomizer_core.py::test_playground_naming_analysis_returns_json_string_keys`. + The red-first command + `PYTHONPATH=python:build/cpp python -m pytest -p no:cacheprovider tests/python/test_modern_atomizer_core.py -q -k naming_analysis_returns_json_string_keys` + reported `1 failed, 27 deselected in 0.39s`; the repaired focused command + reports `1 passed, 27 deselected in 0.33s`. The complete core test file + reports `28 passed in 0.39s`; the modern Atomizer glob reports `142 passed in + 0.61s`; the full Python gate reports `317 passed, 27 skipped, 8 warnings` + in `14.92s`; exact Release/Ninja CTest reports `190/190` in `1.42s`. + Changed-file Ruff (`--no-cache`) passes, Black with the configured + `--target-version py312` reports `2 files would be left unchanged`, and + `git diff --check` passes. The native `build/cpp/bng_cpp` artifact remains + SHA-256 + `949bfff3ea4581a5158df1aa107c21687ef6b848e4692c66215483f315e87d82`. + Exact public branch and PR #2 head readback is + `655810d5e2ea65dc25204f234333a30f170229b1`; hosted CI + [33717241703](https://github.com/RuleWorld/BNG3/actions/runs/33717241703), + CodeQL [33717241701](https://github.com/RuleWorld/BNG3/actions/runs/33717241701), + and formatting + [33717241763](https://github.com/RuleWorld/BNG3/actions/runs/33717241763) + remain queued and are not completion evidence. This closes only the naming + analysis key-shape slice; the richer TypeScript naming-convention + configuration, broader Atomizer behavior, parser/writer/SBML parity, + independent round trips, SBML-Multi, direct-NFsim, legacy, packaging, + release, and hosted validation gaps remain open. +- [ ] Complete or explicitly govern remaining modern reference modules: + atomization/core, parser/bngXmlParser and parser/sbmlParser, + validation/units, writer/bnglWriter, writer/eventActions, and + writer/sbmlWriter. +- [ ] Compare Atomizer molecule/site/state semantics, bond/wildcard handling, + compartments, seed species, observables, rules, rate laws, annotations, + names, and provenance against source-derived fixtures. +- [ ] Route Atomizer output through the canonical parser/AST boundary or record + a reviewed reason for any supported exception. +- [ ] Network failures, missing annotations, unsupported constructs, and + partial conversions produce explicit diagnostics rather than silent loss. + +### 7.2 SBML import/export + +- [ ] SBML XML is schema-valid when the relevant validator is available and + always well-formed with stable diagnostics otherwise. +- [ ] MathML translation covers approved arithmetic, functions, constants, + roots/log bases, identifiers, namespaces, and escaping. +- [ ] Compartments, units, conversion factors, NumberPerQuantityUnit, + concentration/amount semantics, non-finite values, non-integer + stoichiometry, and zero stoichiometry have semantic round-trip tests. +- [ ] Rate rules, assignment rules, initial assignments, events, algebraic + rules, constraints, fast reactions, and package declarations are either + implemented with evidence or fail with an explicit governed diagnostic. +- [ ] SBML IDs and display names remain distinct and stable through generated + parameters, observables, functions, seeds, and reaction rates. +- [ ] The structured SBML atomize=>1 failure is resolved or receives a + maintainer-approved compatibility disposition with a replacement gate. +- [ ] Unsupported SBML packages, qualitative models, dictionaries, and + topology cases are reported in an inspectable unsupported-feature report. + +### 7.3 SBML-Multi + +- [x] Current parser detects and exposes conservative canonical single-level + Multi structures as reference diagnostics. +- [ ] Canonical Multi molecule types, components, states, complexes, + species/seed patterns, bonds, compartments, and annotations are fully + reconstructed from approved fixtures. +- [ ] Multi output is emitted through a supported writer with schema and + semantic round-trip tests. +- [ ] An independent oracle and end-to-end execution semantics are approved. +- [ ] Multi-derived structures are injected into the simulated network only + after the oracle and execution gate pass. +- [ ] Full SBML-Multi simulation, not merely diagnostics/comments, passes Tier-X + and representative NF/network gates. + +### 7.4 All supported formats and graph writers + +- [ ] BNGL import/export is semantically round-trippable. +- [ ] BNG-XML import/export is well-formed, schema/semantic validated, and + preserves supported annotations and rate laws. +- [ ] NET write/read/write is idempotent and graph-aware. +- [ ] SBML and SBML-Multi round trips preserve supported dynamics and metadata. +- [ ] MATLAB/MEX, LaTeX, SSC, MDL, and all documented graph exports have + non-empty, valid, semantically checked outputs. +- [ ] Contact-map, regulatory, rule-influence, reaction-network, RuleViz + pattern/operation, and process graph writers are covered. +- [ ] Every writer has one authoritative implementation or an approved + compatibility disposition; Python and Perl duplicates are not silently used + on the default path. + +## 8. CI, platform, security, and release + +### 8.1 CI truthfulness + +- [x] Historical exact semantic PR head + `0f833470950fc47329f5b7381c64533e623b45ce` has a complete terminal hosted + check set for C++, Python, validation, integration, formatting, ASan, and + CodeQL: CI run [33493581633](https://github.com/RuleWorld/BNG3/actions/runs/33493581633), + CodeQL run [33493581605](https://github.com/RuleWorld/BNG3/actions/runs/33493581605), + and formatting run [33493581573](https://github.com/RuleWorld/BNG3/actions/runs/33493581573) + all passed for this SHA. Pull-request release-only jobs were skipped by + event conditions and remain release-candidate work. This documentation + refresh creates a new public head and requires another exact-head check + readback after push. +- [ ] Historical public semantic checkpoint + `44d8655d3b0d838dc33420c0d7800c12bb465785` has not yet acquired a terminal + hosted check set. At exact-head readback, CI run + [33565168766](https://github.com/RuleWorld/BNG3/actions/runs/33565168766), + formatting run + [33565168844](https://github.com/RuleWorld/BNG3/actions/runs/33565168844), + and CodeQL run + [33565168898](https://github.com/RuleWorld/BNG3/actions/runs/33565168898) + were queued; queued or partial results are not completion evidence. The PR + #2 metadata and branch ref agreed on + `44d8655d3b0d838dc33420c0d7800c12bb465785` immediately after push. Every + later semantic or documentation checkpoint requires a fresh exact-head + readback. +- [ ] Historical public checklist head + `b8dfd027e50b81737c5e8b59f225f9085149f3c0` had fresh hosted runs, all + queued at readback: CI + [33567163033](https://github.com/RuleWorld/BNG3/actions/runs/33567163033), + formatting patch + [33567163155](https://github.com/RuleWorld/BNG3/actions/runs/33567163155), + and CodeQL + [33567163054](https://github.com/RuleWorld/BNG3/actions/runs/33567163054). + Queued status is not validation evidence. +- [ ] Latest published checklist checkpoint + `7b53663d32b35ec1df4057b6a2832d79b0152577` has fresh hosted runs, all + pending at readback: CI + [33568454975](https://github.com/RuleWorld/BNG3/actions/runs/33568454975), + formatting patch + [33568454932](https://github.com/RuleWorld/BNG3/actions/runs/33568454932), + and CodeQL + [33568454970](https://github.com/RuleWorld/BNG3/actions/runs/33568454970). + These checks qualify only `7b53663`; pending status is not validation + evidence. +- [ ] Current public semantic checkpoint + `ead6b8e1513f819ec91571aa0e5ead49aa119a8c` has not yet acquired a terminal + hosted check set. At exact-head readback, CI run + [33570349978](https://github.com/RuleWorld/BNG3/actions/runs/33570349978), + formatting patch run + [33570350028](https://github.com/RuleWorld/BNG3/actions/runs/33570350028), + and CodeQL run + [33570349982](https://github.com/RuleWorld/BNG3/actions/runs/33570349982) + were pending for this exact SHA. Queued or partial results are not + completion evidence; the checklist refresh itself requires another + exact-head readback. +- [ ] Current public semantic checkpoint + `4edf4df57f01d22f15d83ee6635b82e974b0e6dc` has not yet acquired a terminal + hosted check set. At exact-head readback, CI run + [33571418085](https://github.com/RuleWorld/BNG3/actions/runs/33571418085), + formatting patch run + [33571418104](https://github.com/RuleWorld/BNG3/actions/runs/33571418104), + and CodeQL run + [33571418139](https://github.com/RuleWorld/BNG3/actions/runs/33571418139) + were queued for this exact SHA. Queued or partial results are not + completion evidence; the next checklist documentation head requires a + fresh exact-head readback. +- [ ] Current public semantic checkpoint + `88f4e548ed8b7ef43cfc57aa62ad7b7914205613` has not yet acquired a terminal + hosted check set. At exact-head readback, CI run + [33572711786](https://github.com/RuleWorld/BNG3/actions/runs/33572711786), + formatting patch run + [33572711779](https://github.com/RuleWorld/BNG3/actions/runs/33572711779), + and CodeQL run + [33572711794](https://github.com/RuleWorld/BNG3/actions/runs/33572711794) + were queued for this exact SHA. Queued or partial results are not + completion evidence; the next checklist documentation head requires a + fresh exact-head readback. +- [ ] Current public semantic checkpoint + `7241746a50ed0f87f23ad93eda38b2a9e7cca180` has not yet acquired a terminal + hosted check set. At exact-head readback, CI run + [33574600510](https://github.com/RuleWorld/BNG3/actions/runs/33574600510), + formatting patch run + [33574600516](https://github.com/RuleWorld/BNG3/actions/runs/33574600516), + and CodeQL run + [33574600570](https://github.com/RuleWorld/BNG3/actions/runs/33574600570) + were queued for this exact SHA. Queued or partial results are not + completion evidence; the next checklist documentation head requires a + fresh exact-head readback. +- [ ] Current public semantic checkpoint + `413523620b1903f7152a27ee6441b0b4d07b933b` has not yet acquired a terminal + hosted check set. At exact-head readback, CI + [33576632616](https://github.com/RuleWorld/BNG3/actions/runs/33576632616), + formatting patch + [33576632625](https://github.com/RuleWorld/BNG3/actions/runs/33576632625), + and CodeQL [33576632624](https://github.com/RuleWorld/BNG3/actions/runs/33576632624) + were queued for this exact SHA. Queued or partial results are not + completion evidence; the next checklist documentation head requires a + fresh exact-head readback. +- [x] CI truthfulness repair checkpoint + `e7cd59bd793a30d31bf2b8822725e05ba097b3d0` has a source-derived local + fail-closed workflow contract (`8 passed`) and an exact public branch/PR + readback. Its CI, formatting, and CodeQL runs + [33575648537](https://github.com/RuleWorld/BNG3/actions/runs/33575648537), + [33575648574](https://github.com/RuleWorld/BNG3/actions/runs/33575648574), + and [33575648533](https://github.com/RuleWorld/BNG3/actions/runs/33575648533) + were queued at readback. This repair does not check the final hosted gate, + weekly corpus, independent oracle, or release-candidate requirements. +- [x] Strict-reference validation checkpoint + `0e2642aa239c569f66eb550db6c0952219060142` is wired into the PR and weekly + reference jobs, with the current exclusions explicit. Hosted CI + [33577070603](https://github.com/RuleWorld/BNG3/actions/runs/33577070603), + formatting [33577070621](https://github.com/RuleWorld/BNG3/actions/runs/33577070621), + and CodeQL [33577070593](https://github.com/RuleWorld/BNG3/actions/runs/33577070593) + were queued at readback. Queued status is not completion evidence, and the + 35 exclusions remain validation gaps. +- [x] CI validation-governance checkpoint + `4ed849f98f80b9139e0df018f8835025aa6b6410` centralizes the PR and weekly + reference exclusions in `tests/validation/reference_exclusions.json`, adds + strict profile loading to `scripts/validate.py`, and runs the source-derived + CI contract tests in the hosted lint job. Six stale passing exclusions + (`michment_cont`, `test_sbml_flat`, `Repressilator`, `SHP2_base_model`, + `Motivating_example_cBNGL`, and `blbr`) were removed; the current local + profiled validation reports `40` passes, `0` failures, `0` errors, and `31` + explicit skips. The focused contract reports `13 passed`; the proportional + exact-tree CTest/Python gates report `185/185` and `241 passed, 27 skipped, + 8 warnings`. The manifest remains pending maintainer approval, and its 30 + missing-reference entries plus one unsupported native SBML path remain open + validation gaps rather than completion evidence. Public branch and PR #2 + read back to this exact SHA; CI [33579480347](https://github.com/RuleWorld/BNG3/actions/runs/33579480347), + formatting [33579480344](https://github.com/RuleWorld/BNG3/actions/runs/33579480344), + and CodeQL [33579480349](https://github.com/RuleWorld/BNG3/actions/runs/33579480349) + were queued at readback. +- [ ] Every required job emits a terminal summary with counts, failures, + skips, exception budget, corpus/source revision, and artifact digests. +- [ ] Required jobs fail when a claimed oracle, corpus, validator, or compiler + asset is unavailable. +- [ ] No required test is suppressed by || true or an equivalent mechanism. +- [ ] Parse-only inventory jobs are named and described as parse-only; they do + not imply NFsim execution or scientific parity. +- [ ] Formatting/autofix jobs report a patch or fail with remediation; they do + not commit or push to contributor branches. +- [ ] Path-based selection is connected to a complete capability-to-test map; + full main/nightly coverage prevents a path-map omission from becoming a + permanent blind spot. +- [ ] Required PR fast, targeted, main, nightly, weekly, and release-candidate + layers are enabled and reviewed. +- [ ] CODEOWNERS and domain approval requirements cover scientific semantics, + comparators, tolerances, exceptions, provenance, and compatibility changes. + +### 8.2 Platform and quality gates + +- [ ] Supported Linux, macOS, Windows, compiler, Python, and architecture + builds pass on the exact release candidate. +- [ ] C++ unit tests, Python API tests, validation tiers, ASan/UBSan/leak + checks, and integration tests pass without hidden infrastructure failures. +- [ ] Performance benchmarks, memory budgets, and reproducibility rebuilds + pass their approved thresholds. +- [ ] CodeQL or equivalent security analysis passes on the exact release head. +- [ ] Hosted weekly full validation and cross-validation complete with + independent BNG2/NFsim inputs, not just parser inventory. + +### 8.3 Packaging and release + +- [ ] pyproject metadata has the correct BNG3 project/repository URLs, + supported Python range, dependency policy, package data, and extension + contents. +- [x] The pull-request package-smoke job builds and installs a source + distribution on the exact head (CI run + [33449613101](https://github.com/RuleWorld/BNG3/actions/runs/33449613101)); + release-candidate provenance and the complete artifact matrix remain open. +- [ ] Clean isolated wheels build for every supported platform/architecture. +- [ ] Installed-wheel tests cover import, compiled extension loading, API, + CLI, embedded assets, plotting/data helpers, and representative scientific + smoke behavior. +- [ ] CLI binaries and optional native NFsim artifacts are built and tested + where promised. +- [ ] Docker/container artifacts build, run, and have recorded base-image + digests where supported. +- [ ] Release artifacts are content-addressed, reproducible, and tied to the + exact validated SHA. +- [ ] The release workflow cannot publish an unqualified tag or artifacts + lacking the approved provenance/golden report. +- [ ] PyPI/test-index publication is staged or dry-run verified before the + first public release. +- [ ] Hosted release jobs for source distribution, wheels, Docker, and + publication are actually exercised for the release candidate; PR-only + skipped jobs are not counted as evidence. + +## 9. Legacy repositories and governance + +- [ ] BioNetGen, NFsim, and PyBioNetGen source deltas through the accepted + cutoff are reconciled, rejected with rationale, or tracked as blockers. +- [ ] Duplicate Nauty, redundant Network3 solver trees, duplicate model copies, + unreachable notebook wrappers, and other redundant code have explicit + delete lists and zero-reference evidence before removal. +- [ ] BNG2 Perl and native NFsim oracle sources/artifacts remain buildable and + retained for the supported validation window. +- [ ] BNG3 is documented as the sole forward-development repository. +- [ ] External repositories have documented maintenance/retirement state, + migration guidance, contribution redirects, and issue-routing policy. +- [ ] No production component retains two authoritative implementations after + consolidation. +- [ ] Every deletion has a separate reviewable checkpoint after its parity, + compatibility, packaging, and rollback gates pass. + +## 10. Documentation and operational consistency + +- [ ] BNG3_INTEGRATION_PLAN.md, BNG3_unification_spec.md, AGENTS.md, + provenance/README.md, validation/README.md, and this checklist agree on + current gates, command paths, statuses, and ownership. +- [ ] Dated progress counts and hosted run references are refreshed after each + semantic checkpoint; stale historical numbers are labeled as historical. +- [ ] The small pre-existing grammar fix remains preserved and is not mixed + into implementation commits. +- [ ] Every unsupported capability has a user-visible diagnostic, owner, + tracking issue, migration path, and review/expiry date. +- [ ] Developer build/test/release commands are reproducible from a clean + checkout and document required oracle assets. +- [ ] API, CLI, compatibility, deprecation, and release migration documents + are published before deleting supported legacy entry points. +- [ ] Documentation/link checks run in CI. + +## 11. Exact release-candidate qualification + +Run the following on a clean checkout of the exact candidate SHA. Adapt paths +only when the approved environment requires it; record the actual commands and +versions in the release evidence. + + git pull --ff-only + git status --short + git rev-parse HEAD + cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release + cmake --build build + ctest --test-dir build --output-on-failure + PYTHONPATH=python:build/cpp python -m pytest tests/python -q + black --check --target-version py312 python/ tests/python/ scripts/ + ruff check python/ tests/python/ scripts/ + git diff --check + python scripts/validate_provenance.py --require-approved + python scripts/validate_corpus_manifest.py + python scripts/generate_corpus_manifest.py --check + python -m tests.validation.exception_ledger --max-exceptions APPROVED_BUDGET + PYTHONPATH=python:build/cpp python -m pytest -c tests/validation/pytest.ini tests/validation -m smoke --bng-cpp build/cpp/bng_cpp + PYTHONPATH=python:build/cpp python -m pytest -c tests/validation/pytest.ini tests/validation -m "parity and not slow" --bng-cpp build/cpp/bng_cpp + NFSIM_BIN=build/cpp/NFsim PYTHONPATH=python:build/cpp python -m pytest -c tests/validation/pytest.ini tests/validation -m nf --bng-cpp build/cpp/bng_cpp + PYTHONPATH=python:build/cpp python -m pytest -c tests/validation/pytest.ini tests/validation -m export --bng-cpp build/cpp/bng_cpp + python -m build --sdist --wheel + gh pr view 2 --repo RuleWorld/BNG3 + gh pr checks 2 --repo RuleWorld/BNG3 + +The qualification record must include: + +- exact candidate SHA and clean-tree status; +- compiler, Python, CMake, dependency, platform, and container versions; +- complete local test counts and terminal summaries; +- independent BNG2/NFsim/PyBioNetGen artifact digests; +- RuleHub selection and golden-manifest digests; +- comparator versions, tolerances, seeds, time grids, and statistical results; +- every skip/error and its approved ledger entry; +- installed wheel, source distribution, CLI, Docker, and publication results; +- hosted CI, validation, integration, and CodeQL links for the same SHA. + +## 12. Current blockers at the audited checkpoint + +These are known unchecked requirements, not reasons to claim completion: + +- Source lock, oracle recipes/artifact digests, compiler images, Python lock + digest, owners, and RuleHub selectors remain pending maintainer approval. +- No provenance-complete approved golden bundle or complete reconciliation + ledger is present. +- Capability-matrix oracle fields remain pending; broad independent BNG2, + NFsim, and PyBioNetGen parity is not established by local tests. +- The compact energy evaluator is ported through shared partner-pool indexing, + batched add/remove propensity updates, cached compact rate-factor refresh + (`963d01b`), specialized reverse propensities (`dbadea6`), a source-derived + direct-product endpoint identity snapshot used by fired membership refresh + (`4a2fc3e`), safe direct-product traversal (`738c881`), and cached + single-/multi-term Arrhenius rate factors (`6b6e246`), cached simple pre-fire + binding rejection (`bd29714`), candidate bitset/mapping-slot indexing + (`401becf`), deferred multi-product propensity accounting (`6c681269`), + sparse selector integration (`a97c02e`) and cached implicit sparse-batch + old-propensity reuse (`88f4e54`, source `4bb24b3`), guarded Release-LTO + configuration (`7241746`, source `301bfbeb`), indexed cross-type partner + endpoint/decision refresh (`bb3ae014`), source-derived pure-context + complex counting (`bb14a207`), sparse type-invariant membership-decision + indexing (`fbfda3f`), and endpoint-refined membership refresh decisions + from NFsim commit `ced6f60` (`464bd8d`), and all-forward compact partner-pool + early return from NFsim commit `fd01d015` (`2940a02`), plus connected + membership order/template coverage from NFsim commits `051e7e2` and + `23436e2` (`c7dd52d`), plus reusable connectivity direct-product lookup + scratch from NFsim commit `96be0b1` (`a2d7f6c`), plus the functional + symmetry/TotalRate correction from NFsim commits `2778162` and `1b19611` + (`53a3d3d`), plus compact sorted/inline reaction-membership IDs from NFsim + commits `ad4b56a` and `4007795` (`3fb3373`, `0560c3b`), plus lazy + direct-product lookup allocation from NFsim commit `2b3c643` (`2c0b999`). + The direct-AST energy fixtures now cover BNG2-compatible + one-way binding and state-change directionality, including the compact + forward-only path (`72dd033`, `ba52c20`). Merged NFSIM PR #475 full + incremental-membership semantics, remaining direct-product parity, + independent energy parity, benchmark provenance, and source reconciliation + are still open. +- cpp/nfsim/nauty24 and the NFsim ExprTk path remain in the build; the + canonical-label and shared-expression master-function migrations are not + complete. +- Direct NFsim remains a bounded subset with visible XML fallback/shadow + machinery; protocol-NF, remaining function/rate-law, broader Tier-NF, and + full independent evidence remain open. The exact AN2 and IfTest trajectories + are green checkpoints, not substitutes for that broader gate. +- Fixed-seed direct/API NFsim endpoint parity for `motor` and `tlbr` is now + covered by the independent native-oracle contract at `7186b65`; broader + direct-NFsim corpus, protocol, and three-way evidence remain open. +- The source-derived NFsim Issue86 species-observable dependency-refresh + regression is covered by the current `7186b65` 13-assertion AST adapter + test; its independent native-NFsim reduced-fixture cross-check is historical + `852793f` evidence and does not close the broader function/rate-law or + Tier-NF gates. +- The NFsim validation harness now refuses to infer independence from a + BNG3-built binary when `NFSIM_BIN` is absent or invalid. The required + independently built oracle path, source revision, binary digest, and + reproducibility recipe still need maintainer approval and hosted execution. +- The source-derived IfTest conditional-function branch and current-head direct + console route are green, including the `reactant_1` mapper and independent + seeded trajectory. The source-derived AN2 trajectory is exact at `e92b2c9`; + remaining direct-NFsim parity work is the broader Tier-NF corpus, protocol, + and other capability gates below. +- Source performance commit `0463a1f4`'s applicable Macro slice is now ported + and linked at `4edf4df`, with source-derived `num_site` and `pre_macr` + contracts. Full Macro/legacy compatibility, independent benchmark evidence, + and release qualification remain open. +- Structured SBML atomization still has a deliberate visible error, and + local validation has environment-dependent skips; hosted validation green + does not prove full Tier-P/NF/X parity. +- SBML-Multi is currently diagnostic/reference extraction, not approved + end-to-end simulated execution. +- Legacy Python core/modelapi/network/simulator trees remain and have not + passed zero-reference deletion gates. +- Atomizer writer/helper/parser parity and all format round trips remain + broader than the current modern test slices. +- Package metadata/repository URL review, current-head clean release + sdist/wheel matrices, + Docker, publication, and release-artifact provenance remain open; hosted + PR release jobs were skipped by event conditions. +- Focused source-derived checkpoints now cover modern helper/rate-rule + constants, selected atomization/core helpers, and selected writer facades; + broad Atomizer writer/parser/SBML parity is still open. +- CODEOWNERS and complete domain approval enforcement remain open even though + AGENTS.md exists. +- Historical progress text in the integration plan must be refreshed as later + checkpoints land. + +## 13. Recommended execution order + +1. Obtain maintainer decisions and complete the source/oracle/provenance + baseline without changing scientific tolerances. +2. Build independent golden and reconciliation evidence; repair validation + infrastructure so missing assets fail honestly. +3. Complete graph canonicalization and expression master functions, then run + full BNG2/NFsim differential gates. +4. Finish the direct NFsim mapping and three-way shadow gate before removing + XML fallback. +5. Finish Atomizer/SBML/SBML-Multi semantics and all supported format + round trips. +6. Freeze Python/CLI compatibility, then remove redundant default-path trees + in isolated deletion checkpoints. +7. Complete CI ownership, platform, benchmark, clean-package, release, and + provenance gates on one exact candidate SHA. +8. Publish migration/maintenance decisions and only then claim BNG3 + convergence. + +No completion claim is valid until the checklist, the capability matrix, the +unification work orders, and the exact release evidence all agree. diff --git a/docs/BNG3_INTEGRATION_PLAN.md b/docs/BNG3_INTEGRATION_PLAN.md index 8b857f87..1a5a4ec4 100644 --- a/docs/BNG3_INTEGRATION_PLAN.md +++ b/docs/BNG3_INTEGRATION_PLAN.md @@ -3,10 +3,10 @@ **Status:** Active implementation; convergence foundation and direct NFsim migration in progress **Plan date:** 2026-08-28 **Scope:** BioNetGen, NFsim, and PyBioNetGen convergence into one maintained BNG3 codebase -**Last progress update:** 2026-08-29 +**Last progress update:** 2026-08-31 **Implementation:** The current branch owns the active port; this document remains the authoritative backlog and completion contract. Section 11 is not yet satisfied. -## Implementation progress checkpoint — 2026-08-29 +## Implementation progress checkpoint — 2026-08-31 The branch `codex/bng3-integration-foundations` has advanced from the initial foundation into source-led semantic porting. The individual BioNetGen and @@ -35,34 +35,149 @@ Completed slices now present in BNG3 include: - Validation improvements that key network reactions by species content and pool independent stochastic-ensemble standard errors. This does not relax scientific thresholds or replace structural parity with string matching. +- Protocol and parameter-scan actions with explicit value/range precedence, + per-point state isolation, final-observable `.scan` output, and strict + sensitivity `.gsc`/`.csc` artifacts. Native parameter scans now preserve + legacy POSIX process-isolated batches, with an explicit serial fallback on + Windows where embedded fork isolation is unavailable. +- Legacy NFsim zero-order synthesis semantics, including explicit product + operations, compartment-volume/NumberPerQuantityUnit scaling, and direct/XML + parity; partial parameter-scan ranges now preserve explicit-list behavior. +- Legacy NFsim compatibility flags are now parsed through a bounded in-process + adapter, complex bookkeeping is separated from ring-blocking semantics, and + the Michaelis-Menten propensity uses a cancellation-safe tiny-Km root. +- BNGL models without a molecule-types block now infer component multiplicity + and numeric state ranges across all patterns before graph materialization. +- Native integer-state transition syntax (`~^[N]`, `~++`, and `~--`) now + expands into bounded canonical BNGL rules, and loose actions inside a + `begin model` block are accepted for legacy fixtures. +- Native NFsim DOR2 local-function products now remain bounded end-to-end: + raw `fA(x)*fB(y)` rates map directly, serialize as `FunctionProduct`, and + legacy composite `Function` rate laws are accepted by the XML loader. +- Generated XML rate functions now retain scoped local-function arguments and + object references for arithmetic expressions around local calls. Direct + NFsim also accepts the legacy one-ended numeric seed-bond metadata used by + the RNA fixtures, and maps intramolecular bonds on newly created products. +- Python scan and sensitivity forwarding for sample times, stopping, solver + limits, sparse/check controls, and parallel worker payloads. +- NFsim's model-derived universal traversal limit is now applied on the public + Python binding path, while action-level `utl` values remain explicit and are + reported in verbose mode. On the large `models/tlbr` fixture, one seeded + 50-time-unit run measured about 24.9 seconds before this fix and 2.7 seconds + after it. +- Modern SymPy ODE export through the canonical C++ MEX writer, strict action + method validation, and explicit compatibility errors for unsupported + simulator types or missing RoadRunner. +- Playground-derived modern SBML import now normalizes declared unit + definitions before BNGL seed emission, records auditable import warnings, + and conservatively extracts canonical SBML-Multi molecule/complex + references as commented diagnostics. Multi structures are intentionally not + injected into the simulated network until an end-to-end oracle is approved. +- Uniform SBML conversion factors are now folded into generated reaction fluxes; + reactions with mixed per-species factors remain explicitly unscaled and + diagnostic because one BNGL rule cannot represent species-specific flux + scalars. +- SBML rate rules now become positive/negative source/sink rules driven by + explicit rate-rule functions. Targets absent from `listOfSpecies` are + materialized as amount-only synthetic state species, while algebraic rules + remain explicit dropped constraints. +- SBML stoichiometry is now handled without silent integer coercion: explicit + zero references are omitted, fixed integer references are preserved, and + fractional, negative, or non-finite references cause the affected reaction + to be omitted with an explicit provenance warning. Variable or + `stoichiometryMath` references with a finite nonnegative integer value are + retained as a documented fixed-value approximation. +- SBML `fast="true"` reactions and reaction-level `conversionFactor` + declarations are retained in the typed model and surfaced as explicit + approximations when BNGL cannot preserve their semantics. +- SBML events, algebraic rules, constraints, and declared Level 3 packages + now produce counted import diagnostics; qualitative-only `qual` models fail + closed instead of emitting a misleading kinetic network. +- The pure-XML MathML fallback now preserves rational and e-notation + constants, canonical SBML time/Avogadro symbols, constants, roots/log bases, + and user-function applies before BNGL expression conversion. +- BNGL keyword-colliding parameters are renamed consistently in rate laws, + and generated observables are keyed by SBML IDs so display-name changes do + not create undefined rate references. +- SBML constant and boundary-condition species now emit fixed (`$`) BNGL + seeds, preserving their non-dynamic status through the modern writer. +- Expression-valued SBML initial seeds are constant-folded when their + parameters, compartments, functions, and initial values resolve numerically; + unresolved values retain safe emitted identifiers. +- Simple SBML assignment rules composed of species sums now emit BNGL + `Molecules` observables (with amount aliases), and reaction/rate-rule + references resolve those observables without generating undefined functions. +- Non-finite SBML parameter values are curated to finite BNGL literals + (`NaN`→`0`, infinities→signed `1e20`) with an explicit import diagnostic. +- Repeated component names in reactant patterns now remove matching leading + statistical multiplicity factors, leaving BNGL pattern matching to carry the + combinatorics. +- Assignment and rate-rule expressions that reference reaction IDs now inline + the corresponding kinetic flux, covering SBML `rateOf`-style dependencies. +- Parameterized SBML functions are now inlined through either their SBML ID or + declared name, preserving models whose identifiers differ from display names. +- Non-finite SBML compartment sizes now default to unit volume in both the + emitted parameter and compartment declarations, keeping generated BNGL + finite and parseable. +- Reversible SBML net rates are now split into forward and reverse BNGL laws + at top-level additive operators; unsplittable reversible laws degrade to a + valid irreversible rule instead of duplicating a net flux. +- Legacy runner boundaries that preserve C++ parse/execution failures and do + not return an empty successful result when neither backend is available. Local evidence at this checkpoint: -- CTest: 105/105 tests passed. -- Python suite: 101 passed, 27 skipped. -- Black check: 150 files unchanged; Ruff passed. -- Targeted mypy over the validation comparator/oracle modules: no issues. +- CTest: 124/124 tests passed. +- Fast Python suite (`-m 'not slow'`): 155 passed, 27 skipped. +- Full Python suite: 192 passed, 27 skipped. +- Focused modern Playground atomizer suite: 34 passed, including declared + unit scaling, canonical SBML-Multi extraction, conversion-factor + diagnostics, materialized species rate-rule synthesis, stoichiometry + safeguards, fast-reaction handling, unsupported-package diagnostics, and + MathML numeric/function handling, identifier-safe rate/observable mapping, + fixed-seed preservation, expression-seed folding, assignment-rule + observable aliases, non-finite parameter curation, repeated-site + statistical-factor handling, reaction-flux inlining, and non-finite + compartment-size curation, and reversible net-rate splitting. +- Current direct-vs-in-memory-XML NFsim shadow suite: 4/4 passed with the + rebuilt native NFsim executable after the latest adapter slices. +- Current fixed-seed Tier-NF gate: all 4 models × 200 native-oracle runs, plus + the four direct-vs-XML checks, passed 8/8 in 250.07s using eight isolated + workers after the traversal-limit fix. +- Native `t_dor2.bngl` DOR2 smoke: 114 reactions and 113 events; seeded direct + AST output was byte-identical to standalone NFsim using both generated XML + and the original BNG2 XML fixture. +- Ruff passed with `--no-cache`; the repository Ruff cache is not writable in + this checkout. +- The deterministic validation command reports 71 models: 40 passes, 0 + mismatches, 1 explicit error, and 30 skips. - Native NFsim/API seeded ensemble smoke tests passed for `simple_system` and `localfunc` at 200 runs. The larger `motor` diagnostic was not completed within the local runtime budget and is not counted as evidence. - -The exact validation command currently reports 71 models, 32 passes, 4 -failures, and 35 skips. The remaining failures are deliberately visible: - -- `Motivating_example_cBNGL`: two cross-compartment binding reactions still - need source-faithful rejection or product-compartment handling. -- `blbr`: reaction count is now the BNG2 value of 92; remaining text differs - in canonical bond-label orientation and needs structural comparison aligned - with BNG2 `SpeciesList::lookup(check_iso)` semantics. -- `test_network_gen` and `tlbr`: reaction counts match; canonical species - labels still differ and require an explicit graph-equivalence audit rather - than ad hoc string normalization. - -Hosted CI has not been rerun for this checkpoint because the branch has not -been pushed after these changes. Earlier PR results predate the current link, -engine, and adapter fixes and must not be reported as current evidence. No -CodeQL workflow is configured in the checked tree; adding it to the required -CI contract remains open. +- Direct RNA fixture smoke now passes for both `rna_synthesis3.bngl` and + `rna_synthesis4.bngl`; `IfTest/ifTest.bngl`, `t4.bngl`, and `t5.bngl` remain + explicit parse/fixture failures and are not silently admitted to the gate. +- The complex `AN_chemotaxis/an2.bngl` model constructs through both direct and + forced-XML paths, but seeded trajectories differ from the historical BNG2 + XML at later times. This is a diagnostic canonicalization/RNG-order mismatch, + not a parity pass, and is excluded from the completion claim. + +The sole validation error is deliberately visible: + +- `test_sbml_structured`: the C++ flat SBML reader rejects + `atomize=>1` explicitly because structured SBML still requires the Python + atomizer path. The historical reference uses different inferred molecule + naming/topology, so the oracle is not weakened and the error remains a + tracked P5 gap. + +Hosted CI run `33396166182` passed for pushed head `6d99118`, including the C++ +Linux/macOS/Windows builds, ASan, validation, integration tests, and the +complete Python platform matrix. The legacy Ubuntu Python 3.9/3.10 jobs also +passed after seeding an isolated Matplotlib bundled-font cache. The direct +AST-vs-in-memory-XML NFsim shadow gate passed 4/4 models, and the current +fixed-seed native NFsim gate passed all 4 models × 200 runs locally after the +traversal-limit fix. Checkpoint `94d6079` is pushed and its hosted CI is +currently running; no result from that run is counted until it completes. ## Completion charter — port everything @@ -146,7 +261,7 @@ trail in the repository. ### Initial starting checkpoint The public branch `codex/bng3-integration-foundations` and PR -`RuleWorld/BNG3#1` contain the initial provenance, CI, validation, expression, +`RuleWorld/BNG3#2` contain the initial provenance, CI, validation, expression, and bounded direct-NFsim foundation. Before changing code, verify their live heads with `gh`; do not assume the PR description is current. @@ -263,18 +378,19 @@ The work therefore starts from an incomplete convergence, not from four empty re | Area | Current condition | Planning consequence | |---|---|---| -| Source provenance | No single lock file records all imported source revisions and reconciliation status. | Freeze and record the common ground before further convergence. | +| Source provenance | `provenance/upstreams.lock.yml` records the observed source revisions, but maintainer approval and oracle artifacts remain pending. | Review and approve the lock before using it as a release cutoff. | | Direct NFsim bridge | `NFinput_fromAst.cpp` now maps a substantial typed subset: options, parameters, compartments, molecule types, functions, observables, seed species, rules, transformations, filters, dynamic rates, and symmetry. | Keep unsupported forms explicit and finish independent BNG2/NFsim differential coverage before declaring the adapter complete. | | Active NFsim path | The direct adapter and XML bridge both exist; XML remains needed for shadow comparison and compatibility while direct parity is incomplete. | Keep the XML path as a temporary comparator, then remove it from the default runtime only after the Tier-NF gate passes. | -| Graph identity | BioNetGen network canonicalization and NFsim complex identity remain different scientific contracts; several current `.net` failures are label-orientation differences. | Use the individual BNG2/NFsim implementations as authority and compare graph identity structurally, without forcing one unproven high-level labeling algorithm onto both engines. | +| Graph identity | Structural `.net` comparison now passes the available deterministic models; BNG2/NFsim identity contracts still need broader differential coverage. | Expand independent corpus coverage without replacing graph comparison with string matching. | | Expression evaluation | Several dynamic global, local, TFUN, composite, and bounded rate forms are direct; the full shared expression contract is not complete. | Preserve fail-closed behavior for unsupported forms and define one expression contract before removing specialized evaluators. | +| SBML units and Multi package | The modern Playground-derived parser now scales declared quantities and extracts canonical single-level Multi structures as explicit reference diagnostics; Multi output is not yet simulated. | Add independent unit/round-trip oracles and an approved end-to-end Multi execution path before claiming full Tier-X support. | | Python convergence | Legacy `core`, `modelapi`, `network`, and `simulator` trees remain. | Inventory public compatibility before deletion; use contract tests to govern removal. | | Golden references | The validation corpus and BNG2 `.net` references are present, but a reviewed provenance-complete release golden bundle is not frozen. | Build provenance-aware oracle generation before using parity as a release claim. | | Stochastic parity | Seeded determinism and 200-run local smoke comparisons are covered for selected NFsim models; the full distributional gate is not complete. | Keep fixed ensembles and pooled independent-ensemble error rules; execution success is insufficient. | -| RuleHub integration | The local corpus loader does not use a pinned RuleHub snapshot. | Add an exact RuleHub revision and generated selection manifest. | -| CI truthfulness | Local C++/Python/lint/type gates pass, but exact network validation still has four visible failures and hosted CI has not been rerun on the current unpushed branch. No CodeQL workflow is present. | Make required CI honest and green, add security coverage, and rerun it on the exact pushed SHA before claiming completion. | +| RuleHub integration | A pinned RuleHub revision and selection manifest are committed, but both remain pending maintainer approval. | Approve selectors and external tier membership before release use. | +| CI truthfulness | Local C++/Python/Ruff gates pass; deterministic validation has one explicit structured-SBML error; hosted CI run `33362472735` is green on `05bbc88`. The separate CodeQL result and the structured-SBML disposition remain open. | Read back CodeQL on the exact head, resolve or govern the SBML gap, and keep required checks tied to the final release SHA. | | Documentation | The architecture document, unification spec, analysis notes, and live implementation disagree in places. | Add documentation consistency checks and name one governing decision record. | -| Packaging | Shared-extension static linking was corrected locally and the Python/CLI smoke paths pass; current hosted wheel evidence is stale. | Re-run clean wheel, CLI, import, and embedded-data gates on the current SHA. | +| Packaging | Shared-extension static linking was corrected locally; hosted Python matrix wheel, import, CLI, and test jobs pass on `05bbc88`. Release-only wheel/publish jobs remain unrun on this PR. | Re-run clean release wheel, CLI, import, and embedded-data gates on the exact release candidate. | ### 2.3 Source revisions observed on 2026-08-28 @@ -663,15 +779,15 @@ Use Jules Playground as a valuable independently implemented differential target Each phase has deliverables and an exit gate. Later phases may prepare in parallel, but deletion and authority changes follow the stated dependencies. -### Current phase status — 2026-08-29 +### Current phase status — 2026-08-30 | Phase | Status | Evidence and next gate | |---|---|---| | 0 — authority/common ground | In progress | Individual BNG2/NFsim source paths are being used for semantic decisions; the accepted source lock, complete reconciliation ledger, owners, and RuleHub selection manifest remain open. | -| 1 — honest green CI | In progress | Local CTest, Python, formatting, Ruff, and targeted mypy pass; exact network validation has 4 visible failures, hosted CI is stale for this branch, and CodeQL is not yet configured. | -| 2 — independent validation | In progress | BNG2 `.net` corpus comparison, native NFsim seeded smoke ensembles, pooled-error comparator coverage, and provenance scaffolding exist; the reviewed golden bundle and complete independent-oracle gate remain open. | -| 4 — semantic core | In progress | BNG2-derived deletion, bond-cardinality, product-molecularity, symmetry, compartment, and dynamic-rate slices are implemented; canonical graph-label parity and remaining cBNGL behavior are open. | -| 5 — direct NFsim | In progress | Typed AST-to-NFsim construction and direct-vs-XML tests cover a substantial subset; full Tier-NF coverage, native-oracle parity, and XML-path retirement remain open. | +| 1 — honest green CI | In progress | Local CTest 124/124, Python 155 passed/27 skipped, and Ruff pass; deterministic validation has one explicit structured-SBML error, and the current hosted platform matrix is still running. | +| 2 — independent validation | In progress | Structural BNG2 `.net` comparison, a fixed 4-model × 200-seed native NFsim gate, pooled-error comparator coverage, and provenance scaffolding exist; the reviewed golden bundle and broader independent-oracle gate remain open. | +| 4 — semantic core | In progress | BNG2-derived deletion, bond-cardinality, product-molecularity, symmetry, compartment, dynamic-rate, protocol, scan, and sensitivity slices are implemented; broader source differential coverage remains open. | +| 5 — direct NFsim | In progress | Typed AST-to-NFsim construction, scoped XML rate preservation, legacy DOR2/RNA compatibility, intramolecular product bonds, traversal-limit handling, direct-vs-XML tests, and the full 4-model × 200-seed native Tier-NF gate now cover additional behavior; the AN2 mismatch, protocol NF support, and XML-path retirement remain open. | | 6–8 — consolidation/release | Not started | Dependent on the authority, parity, packaging, CI, and provenance exit gates above. | ### Phase 0 — Establish authority and freeze the common ground diff --git a/docs/api_reference.md b/docs/api_reference.md index a78c7e06..f43349c2 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -106,7 +106,9 @@ Generate the reaction network, caching it on the model. #### `simulate(method="ode", t_end=100.0, n_steps=100, ...) -> SimResult` -Run a simulation using one of the bundled engines. +Run a simulation using one of the bundled engines. For `method="nf"`, +`traversal_limit=-1` uses the model-derived NFSim neighborhood limit; a +non-negative value mirrors NFSim's `-utl` control. #### `execute(verbose=False)` diff --git a/docs/architecture.md b/docs/architecture.md index 8eb18b68..110f8d97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -106,20 +106,23 @@ python/bionetgen/ ## NFSim Integration Strategy -NFSim is integrated via an XML bridge rather than direct data model unification: +The default NFSim route now constructs `NFcore::System` directly from the +canonical AST: 1. BNG C++ parses BNGL → `ast::Model` -2. `XmlWriter::write()` serializes to BNG-XML string -3. XML written to temp file -4. `NFinput::initializeFromXML()` reads XML → `NFcore::System` -5. `System::sim()` runs network-free simulation -6. Observable values extracted after simulation - -This approach was chosen because: -- Both sides are extensively tested against the XML format -- Avoids the complex task of unifying two different data models -- Can be transparently replaced with direct bridge later (Phase 3) -- Serialization overhead is negligible vs simulation time +2. `NFinput::buildSystemFromAst()` maps parameters, molecule types, species, + observables, functions, energy patterns, and reaction rules +3. The model-derived universal traversal limit is applied (or an explicit + Python `traversal_limit`/action `utl` value is honored) +4. `System::prepareForSimulation()` and `System::stepTo()` run the network-free + simulation +5. Observable values are copied into NumPy arrays + +The in-memory XML initializer remains an explicit compatibility fallback and a +shadow-comparison oracle while direct parity is qualified. The on-disk XML +initializer is a last-resort compatibility path for legacy models. Direct +construction is fail-closed: unsupported AST constructs do not silently change +semantics by falling back unless the caller opts into XML compatibility. ## Memory Management diff --git a/legacy/perl/Perl2/BNGAction.pm b/legacy/perl/Perl2/BNGAction.pm index 347d8618..72fc9d0b 100644 --- a/legacy/perl/Perl2/BNGAction.pm +++ b/legacy/perl/Perl2/BNGAction.pm @@ -1060,7 +1060,7 @@ sub simulate_nf else { send_warning( "system state was not retrieved following simulate_nf. " - ."To retreive system state, call simulate_nf with option: get_final_state=>1." ); + ."To retrieve system state, call simulate_nf with option: get_final_state=>1." ); } $model->Time($t_end); diff --git a/legacy/perl/Perl2/BNGOutput.pm b/legacy/perl/Perl2/BNGOutput.pm index a7e7da64..1cdab16f 100644 --- a/legacy/perl/Perl2/BNGOutput.pm +++ b/legacy/perl/Perl2/BNGOutput.pm @@ -1389,7 +1389,7 @@ sub writeSSCcfg my $version = BNGversion(); open( SSCcfgfile, ">$file" ) || die "Couldn't open $file: $!\n"; - print STDOUT "\n Writting SSC cfg file \n"; + print STDOUT "\n Writing SSC cfg file \n"; print SSCcfgfile "# SSC cfg file for model $model_name created by BioNetGen $version\n"; print SSCcfgfile $model->ParamList->writeSSCcfg(); @@ -3934,12 +3934,13 @@ sub writeMfile_QueryNames my $q_mscript = 'QueryNames.m'; - open(Q_Mscript,">$q_mscript"); + open( Q_Mscript, '>', $q_mscript ) + or return "Couldn't open $q_mscript for writing: $!\n"; print Q_Mscript <<"EOF"; function [ param_labels, param_defaults, obs_labels, species_labels] = QueryNames( inputlist ) % % Loads all the parameter labels, parameter defaults, observable labels and species labels in the model -% % If generate_network() was executed, then the nanmes of all species are passed -% % If generate_network() was not executed, then the names of the seed speceis are passed +% % If generate_network() was executed, then the names of all species are passed +% % If generate_network() was not executed, then the names of the seed species are passed param_labels = { $mscript_param_names }; param_defaults = [ $mscript_param_values ]; diff --git a/legacy/perl/Perl2/BNGUtils.pm b/legacy/perl/Perl2/BNGUtils.pm index c24f69c2..2fcc81cd 100644 --- a/legacy/perl/Perl2/BNGUtils.pm +++ b/legacy/perl/Perl2/BNGUtils.pm @@ -17,6 +17,7 @@ use constant DEFAULT_CODENAME => ""; require Exporter; our @ISA = qw( Exporter ); our @EXPORT = qw( BNGversion BNGcodename compareVersions isReal booleanToInt BNGroot BNGpath exit_error send_warning cpu_time average_runs create_sbml + send_error head split_obj split_comp verify_pattern process_val_string validate_rate_law MIN MAX SQR log10 ); diff --git a/legacy/perl/Perl2/Console.pm b/legacy/perl/Perl2/Console.pm index 69ee70dc..8a1bda92 100644 --- a/legacy/perl/Perl2/Console.pm +++ b/legacy/perl/Perl2/Console.pm @@ -64,7 +64,7 @@ sub BNGconsole { if (defined $model) { - send_warning( "Attempted to load model while another model is active." ); + send_error( "Attempted to load model while another model is active." ); last PROCESS_INPUT; } @@ -84,7 +84,7 @@ sub BNGconsole unless ($filename and -e $filename) { - send_warning( "Attempted to load model, but file '$filename' was not found." ); + send_error( "Attempted to load model, but file '$filename' was not found." ); last PROCESS_INPUT; } @@ -110,7 +110,7 @@ sub BNGconsole } } else{ - send_warning( "Error processing command line arguments: " . $linein ); + send_error( "Error processing command line arguments: " . $linein ); last PROCESS_INPUT; } $local_params->{$key} = $val; @@ -126,7 +126,7 @@ sub BNGconsole { # undefine model and send warning %$model = (); undef %$model; $model = undef; $BNGModel::GLOBAL_MODEL = undef; - send_warning( "Some problem processing '$filename': $err" ); + send_error( "Some problem processing '$filename': $err" ); last PROCESS_INPUT; } } @@ -137,7 +137,7 @@ sub BNGconsole { unless (defined $model) { - send_warning( "Attempt to execute action without loading model." ); + send_error( "Attempt to execute action without loading model." ); last PROCESS_INPUT; } @@ -149,14 +149,14 @@ sub BNGconsole } else { - send_warning( "Invalid action syntax. Try: action actionName(options)." ); + send_error( "Invalid action syntax. Try: action actionName(options)." ); last PROCESS_INPUT; } # check if action is valid if (!$model->can($action)) { - send_warning( "Problem executing action: Invalid action: $action." ); + send_error( "Problem executing action: Invalid action: $action." ); last PROCESS_INPUT; } print "Begin action $action\n"; @@ -166,7 +166,7 @@ sub BNGconsole my $err = $model->ParamList->check(); if ($err) { - send_warning( "Problem executing action: paramlist failed consistency check." ); + send_error( "Problem executing action: paramlist failed consistency check." ); last PROCESS_INPUT; } } @@ -177,12 +177,12 @@ sub BNGconsole my ($err, $eval_err) = BNGModel::_invoke_model_action($model, $action, $options); if ($eval_err) { - send_warning("Problem executing action: $eval_err."); + send_error("Problem executing action: $eval_err."); last PROCESS_INPUT; } if ($err) { - send_warning("Problem executing action: $err"); + send_error("Problem executing action: $err"); last PROCESS_INPUT; } my $t_elapsed = cpu_time($t_start); @@ -195,7 +195,7 @@ sub BNGconsole { unless (defined $model) { - send_warning( "Attempt to clear model without loaded model." ); + send_error( "Attempt to clear model without loaded model." ); last PROCESS_INPUT; } # undefine model diff --git a/legacy/perl/Perl2/Expression.pm b/legacy/perl/Perl2/Expression.pm index 6b0c9bbb..60963d11 100644 --- a/legacy/perl/Perl2/Expression.pm +++ b/legacy/perl/Perl2/Expression.pm @@ -1871,7 +1871,7 @@ sub toCVodeString my $fcn = $fcn_name; # we can't call function by name, so we have to expand the function expression with args in place (my $local_fcn) = $fcn->evaluate_local( [@{$expr->Arglist}], $plist, $level+1 ); - unless (defined $local_fcn) { die "Error in Expression->toMatlabString(): some problem evaluating anonymous function"; } + unless (defined $local_fcn) { die "Error in Expression->toCVodeString(): some problem evaluating anonymous function"; } $string = $local_fcn->Expr->toCVodeString($plist, $level+1, $expand); } elsif ( isBuiltIn($fcn_name) ) @@ -1898,7 +1898,7 @@ sub toCVodeString elsif( $fcn_name eq 'sum' or $fcn_name eq 'avg') { my @sarr = ( map {$_->toCVodeString($plist, $level+1)} @{$expr->Arglist}[1..$#{$expr->Arglist}] ); - if ( @sarr ge 1 ) + if ( @sarr >= 1 ) { $string = '('. $sarr[0]; for(my $i=1; $i < scalar(@sarr); $i++){ @@ -1965,16 +1965,21 @@ sub toCVodeString } else { + # Convert operators that have different spellings in Perl and C. + my $operator = $type; + $operator = '!=' if $operator eq '~='; + $operator = '!' if $operator eq '~'; + # handling some other operator (+,-,*,/) # enclose in brackets (always. just to be safe) my @sarr = ( map {$_->toCVodeString($plist, $level+1, $expand)} @{$expr->Arglist} ); if ( @sarr > 1 ) { # binary or higher order - $string = '(' . join( $type, @sarr ) . ')'; + $string = '(' . join( $operator, @sarr ) . ')'; } else { # unary operator - $string = '(' . $type . $sarr[0] . ')'; + $string = '(' . $operator . $sarr[0] . ')'; } } diff --git a/legacy/perl/Perl2/MacroBNGModel.pm b/legacy/perl/Perl2/MacroBNGModel.pm index 8735b118..f4df491e 100644 --- a/legacy/perl/Perl2/MacroBNGModel.pm +++ b/legacy/perl/Perl2/MacroBNGModel.pm @@ -86,7 +86,7 @@ $err = $slist->readString($entry,$base_model->ParamList,$base_model->MoleculeTyp $file= "macr_".$param_prefix.".bngl"; rename($file,$filen); - open (WFILEbngl, ">$file") or die "Can't open $file: $!\n"; + open (WFILEbngl, ">", $file) or die "Can't open $file: $!\n"; print WFILEbngl $simul1; close (WFILEbngl); $params{file}=$file; # macr_fceri_ji3.bngl @@ -113,18 +113,18 @@ my ($param_prefix) = @_; my %bngdata; - $bnglfile= "<${param_prefix}.bngl"; # $bnglfile= "<${param_prefix}.bngl"; - $recfile= ">macr_${param_prefix}.rec"; # $recfile= ">macr_${param_prefix}.rec"; - $parfile= ">macr_${param_prefix}.par"; # $parfile= ">macr_${param_prefix}.par"; + $bnglfile= "${param_prefix}.bngl"; # $bnglfile= "${param_prefix}.bngl"; + $recfile= "macr_${param_prefix}.rec"; # $recfile= "macr_${param_prefix}.rec"; + $parfile= "macr_${param_prefix}.par"; # $parfile= "macr_${param_prefix}.par"; - open (WFILErec, $recfile) or die "Can't open $recfile: $!\n"; - open (WFILEpar, $parfile) or die "Can't open $parfile: $!\n"; + open (WFILErec, ">", $recfile) or die "Can't open $recfile: $!\n"; + open (WFILEpar, ">", $parfile) or die "Can't open $parfile: $!\n"; print WFILErec $recfile,"\n"; READ: print "Reading from file $bnglfile\n"; - if (!open(FH, $bnglfile)) { + if (!open(FH, "<", $bnglfile)) { return("Couldn't read from file $bnglfile: $!"); } $file_dat=[]; @@ -143,7 +143,7 @@ READ: if ($err){ last READ;} $bngdata{$name}=1; if ($name eq "parameters"){ - open (WFILEpar, $parfile) or die "Can't open $parfile: $!\n"; + open (WFILEpar, ">", $parfile) or die "Can't open $parfile: $!\n"; print WFILEpar "begin parameters\n"; for my $line (@{$block_dat}){ my ($entry, $lno)= @{$line}; @@ -332,17 +332,17 @@ READ: print WFILErec " BEGIN COR_NET \n"; - $macrfile= ">macr_${param_prefix}.bngl"; # $macrfile= ">macr_${param_prefix}.bngl"; - $parfile= "", $macrfile) or die "Can't open $macrfile: $!\n"; + open (RFILEpar, "<", $parfile) or die "Can't open $parfile: $!\n"; + open (RFILEspec2, "<", $spec2file) or die "Can't open $spec2file: $!\n"; + open (RFILErules, "<", $rulesfile) or die "Can't open $rulesfile: $!\n"; + open (RFILEobser, "<", $obserfile) or die "Can't open $obserfile: $!\n"; while ($line=) { # *.par print WFILEmacr "$line"; # macr_*.bngl @@ -396,7 +396,7 @@ foreach $ii (@species2) { foreach (@sit) { s/[!~].*//; } foreach (@sit) { $nm_site->{$name.":".$_}++; } # L:l 2; while(($key,$val) = each(%$nm_site)) { - if ( $key=~/:/ && $val gt 1 ) { $nm2_site->{$key} = $val; } + if ( $key=~/:/ && $val > 1 ) { $nm2_site->{$key} = $val; } } $nm_site->{$name}= scalar(@sit); # L 2 }##if !(exists @@ -417,8 +417,8 @@ my ($nm_site, $nm2_site, $param_prefix, $skf, $dpp_site, $lis1h) = @_; # ----------RULES------------- - $rulesfile= ">macr_${param_prefix}.rules"; - open (WFILErules, $rulesfile) or die "Can't open $rulesfile: $!\n"; + $rulesfile= "macr_${param_prefix}.rules"; + open (WFILErules, ">", $rulesfile) or die "Can't open $rulesfile: $!\n"; print WFILErules "begin reaction_rules\n"; @lis1 = (); &del_blank(\@reac1tion,\@lis1); @@ -933,8 +933,8 @@ my (%m_spe,%s_spe,%rea_l,%rea_r,%rrea_l,%rrea_r,%dubl); %ts3n = (); @species = (); - $recfile= " ) { if ( ($line =~ / MINIMUM set/) ) { @@ -988,7 +988,7 @@ my (%m_spe,%s_spe,%rea_l,%rea_r,%rrea_l,%rrea_r,%dubl); $rabfile= "macr_${param_prefix}.rab"; $netfile= "macr_${param_prefix}.net"; open (RFILEnet, "<", $netfile) or die "Can't open $netfile: $!\n"; - open (WFILErab, ">", $rabfile) or die "Can't open $netfile: $!\n"; + open (WFILErab, ">", $rabfile) or die "Can't open $rabfile: $!\n"; while ( $line= ) { # search-copy "begin molecule types" if ( $line =~ /begin molecule types/ ) { @@ -1393,8 +1393,8 @@ my ($nm_site, $nm2_site, $param_prefix, $skf, $dpp_site, $lis1h) = @_; my ($ii); my (@lis1); - $obserfile= ">macr_${param_prefix}.obser"; # $obserfile= ">macr_${param_prefix}.obser"; - open (WFILEobser, $obserfile) or die "Can't open $obserfile: $!\n"; + $obserfile= "macr_${param_prefix}.obser"; # $obserfile= "macr_${param_prefix}.obser"; + open (WFILEobser, ">", $obserfile) or die "Can't open $obserfile: $!\n"; print WFILEobser "begin observables\n"; @lis1 = (); @@ -1418,8 +1418,8 @@ close(WFILEobser); # --------- SPECIES2------------- - $spec2file= ">macr_${param_prefix}.spec2"; # $spec2file= ">macr_${param_prefix}.spec2"; - open (WFILEspec2, $spec2file) or die "Can't open $spec2file: $!\n"; + $spec2file= "macr_${param_prefix}.spec2"; # $spec2file= "macr_${param_prefix}.spec2"; + open (WFILEspec2, ">", $spec2file) or die "Can't open $spec2file: $!\n"; print WFILEspec2 "begin species\n"; # *.spec2 @lis1 = (); diff --git a/legacy/perl/Perl2/Molecule.pm b/legacy/perl/Perl2/Molecule.pm index 175b3b9c..9a8b138e 100644 --- a/legacy/perl/Perl2/Molecule.pm +++ b/legacy/perl/Perl2/Molecule.pm @@ -110,6 +110,14 @@ sub readString # Read attributes in braces elsif ( $string_left =~ s/^[{]// ) { + # MatchOnce and Fixed are SpeciesGraph attributes. Leave them + # for SpeciesGraph::readString even when no whitespace separates + # the final molecule from the graph modifier. + if ( $string_left =~ /^(?:MatchOnce|Fixed)(?:=|[,}])/ ) + { + $string_left = "{" . $string_left; + last; + } while ( !( $string_left =~ s/^\}// ) ) { my $attr = ''; diff --git a/legacy/perl/Perl2/ParamList.pm b/legacy/perl/Perl2/ParamList.pm index 4da83fb6..12bdca79 100644 --- a/legacy/perl/Perl2/ParamList.pm +++ b/legacy/perl/Perl2/ParamList.pm @@ -815,6 +815,15 @@ sub check } if ($err) { return ($err) }; + # Named functions are represented as parameters, so the ordinary + # expression dependency walk does not descend into their definitions. + # Check the function call graph explicitly before any later writer or + # evaluator can recurse through a cycle. + if ( $err = _check_function_cycles($plist) ) + { + return ($err); + } + foreach my $param ( @{$plist->Unchecked} ) { #printf "Checking parameter %s for cycles.\n", $param->Name; @@ -836,6 +845,63 @@ sub check return ($err); } +sub _check_function_cycles +{ + my $plist = shift; + my %visiting; + my %visited; + my @path; + + foreach my $param ( @{$plist->Array} ) + { + next unless defined $param->Type && $param->Type eq 'Function'; + my $err = _visit_function( $plist, $param, \%visiting, \%visited, \@path ); + return $err if $err; + } + + return ''; +} + +sub _visit_function +{ + my ($plist, $param, $visiting, $visited, $path) = @_; + my $name = $param->Name; + + if ( $visiting->{$name} ) + { + my $start = 0; + ++$start while $start < @$path && $path->[$start] ne $name; + my @cycle = ( @$path[$start .. $#$path], $name ); + return 'Function dependency cycle: ' . join( ' -> ', @cycle ); + } + return '' if $visited->{$name}; + + $visiting->{$name} = 1; + push @$path, $name; + + my $dependencies = $param->Expr->getVariables($plist); + if ( exists $dependencies->{'Function'} ) + { + foreach my $dependency_name ( keys %{$dependencies->{'Function'}} ) + { + my ($dependency) = $plist->lookup($dependency_name); + next unless $dependency && defined $dependency->Type && $dependency->Type eq 'Function'; + my $err = _visit_function( $plist, $dependency, $visiting, $visited, $path ); + if ($err) + { + pop @$path; + delete $visiting->{$name}; + return $err; + } + } + } + + pop @$path; + delete $visiting->{$name}; + $visited->{$name} = 1; + return ''; +} + ### diff --git a/legacy/perl/Perl2/RxnRule.pm b/legacy/perl/Perl2/RxnRule.pm index 40109ff5..6d91877d 100644 --- a/legacy/perl/Perl2/RxnRule.pm +++ b/legacy/perl/Perl2/RxnRule.pm @@ -2715,6 +2715,48 @@ sub findMap # Find reactant graph automorphisms my @r_auto = $rg->isomorphicToSubgraph($rg); + # The aggregate graph loses the boundary between reactant patterns. Keep + # only automorphisms that map each pattern's complete molecule set to a + # complete pattern molecule set; otherwise disconnected molecules from two + # identical patterns can be exchanged independently and over-correct the + # statistical factor. + my @pattern_r_auto = (); + foreach my $auto (@r_auto) + { + my %pattern_image = (); + my %image_pattern = (); + my $preserves_patterns = 1; + foreach my $src_ptr (keys %{$auto->MapF}) + { + next unless $src_ptr =~ /^\d+$/; + my $targ_ptr = $auto->MapF->{$src_ptr}; + unless (defined $targ_ptr && $targ_ptr =~ /^\d+$/ && + defined $aggMapR[$src_ptr] && defined $aggMapR[$targ_ptr]) + { + $preserves_patterns = 0; + last; + } + + my ($src_pattern) = ($aggMapR[$src_ptr] =~ /^(\d+)/); + my ($targ_pattern) = ($aggMapR[$targ_ptr] =~ /^(\d+)/); + if (defined $pattern_image{$src_pattern} && + $pattern_image{$src_pattern} != $targ_pattern) + { + $preserves_patterns = 0; + last; + } + if (defined $image_pattern{$targ_pattern} && + $image_pattern{$targ_pattern} != $src_pattern) + { + $preserves_patterns = 0; + last; + } + $pattern_image{$src_pattern} = $targ_pattern; + $image_pattern{$targ_pattern} = $src_pattern; + } + push @pattern_r_auto, $auto if $preserves_patterns; + } + @r_auto = @pattern_r_auto; # Find product graph automorphisms my @p_auto = $pg->isomorphicToSubgraph($pg); diff --git a/legacy/perl/Perl2/SpeciesGraph.pm b/legacy/perl/Perl2/SpeciesGraph.pm index 9e514e33..44d50316 100644 --- a/legacy/perl/Perl2/SpeciesGraph.pm +++ b/legacy/perl/Perl2/SpeciesGraph.pm @@ -266,7 +266,14 @@ sub readString # Handle continuation and stopping # molecule separator characters next if ( $string_left =~ s/^\.// ); - last if ( $stop and $string_left =~ /$stop/ ); + if ( $stop and $string_left =~ /$stop/ ) + { + # Graph modifiers may be separated from the final molecule by + # whitespace. Consume only that whitespace so the modifier is + # handled below; ordinary whitespace still terminates the graph. + next if ( $string_left =~ s/^\s+(?=\{(?:MatchOnce|Fixed)(?:=|[,}]))// ); + last; + } # Handle modifier syntax if ( $string_left =~ s/^\$// ) @@ -2028,6 +2035,13 @@ sub toXML if ( $sg->Quantifier ) { my ( $relation, $quantity ) = ( $sg->Quantifier =~ /(=|==|<=|>=|<|>)(\d+)/ ); + my %xml_relation = ( + '<' => '<', + '>' => '>', + '<=' => '<=', + '>=' => '>=', + ); + $relation = $xml_relation{$relation} if exists $xml_relation{$relation}; $string .= ' relation="' . $relation . '" quantity="' . $quantity . '"'; } diff --git a/legacy/perl/Perl2/Visualization/NetworkGraph.pm b/legacy/perl/Perl2/Visualization/NetworkGraph.pm index 810e94c8..4843773c 100644 --- a/legacy/perl/Perl2/Visualization/NetworkGraph.pm +++ b/legacy/perl/Perl2/Visualization/NetworkGraph.pm @@ -298,8 +298,11 @@ sub unprettify my $string = shift @_; $string =~ s/\s//g; $string =~ s/\(\)//g; - $string =~ s/^0//g; - $string =~ s/0$//g; + # Remove only the synthetic zero used for an empty rule side. Molecule + # names may legitimately begin or end with the character "0" (for example, + # ZAP70), so an unrestricted trim corrupts graph labels. + $string =~ s/^0(?=->)//; + $string =~ s/(?<=->)0$//; return $string; } @@ -978,4 +981,3 @@ sub duplicateNetworkGraph } 1; - diff --git a/python/bionetgen/__init__.py b/python/bionetgen/__init__.py index 565dfac7..df6ad9f0 100644 --- a/python/bionetgen/__init__.py +++ b/python/bionetgen/__init__.py @@ -14,11 +14,51 @@ from bionetgen.compat.legacy_runner import LegacyModel as BioNetGenModel else: try: - from bionetgen.model import BioNetGenModel, load, run + from bionetgen.model import BioNetGenModel, load, run as _modern_run except ImportError: from bionetgen.compat.legacy_runner import load, run from bionetgen.compat.legacy_runner import LegacyModel as BioNetGenModel + _MODERN_METHODS = frozenset({"ode", "ssa", "nf", "pla", "psa"}) + _RUN_OUT_MISSING = object() + + def run(path, method="ode", t_end=100.0, n_steps=100, **kwargs): + """Run a model through the modern or PyBioNetGen-compatible API. + + The modern form is ``run(path, method="ode", ...)`` and returns a + :class:`SimResult`. A path-like second positional argument, or the + legacy ``out=`` keyword, selects the PyBioNetGen file-runner contract + and returns a :class:`BNGResult`. + """ + legacy_out = kwargs.pop("out", _RUN_OUT_MISSING) + legacy_requested = legacy_out is not _RUN_OUT_MISSING + + if isinstance(method, str): + method_name = method.lower() + else: + method_name = None + + if method_name not in _MODERN_METHODS: + legacy_requested = True + if legacy_out is _RUN_OUT_MISSING: + legacy_out = method + method = None + + if legacy_requested: + from bionetgen.compat.runner import run as _compat_run + + return _compat_run( + path, + out=None if legacy_out is _RUN_OUT_MISSING else legacy_out, + method=None if method == "ode" else method, + t_end=t_end, + n_steps=n_steps, + **kwargs, + ) + + return _modern_run(path, method=method, t_end=t_end, n_steps=n_steps, **kwargs) + + from bionetgen.result import SimResult from bionetgen.scan import ScanResult, ScanResult2D, parameter_scan, parameter_scan_2d from bionetgen.sensitivity import SensitivityResult, sensitivity_analysis @@ -45,9 +85,41 @@ __version__ = "3.0.0a1" + +def __getattr__(name): + """Load legacy public names lazily to keep optional imports optional.""" + if name == "bngmodel": + from bionetgen.modelapi.model import bngmodel + + return bngmodel + if name == "sim_getter": + from bionetgen.simulator.simulators import sim_getter + + return sim_getter + if name == "BNGResult": + from bionetgen.core.tools.result import BNGResult + + return BNGResult + if name in {"SympyOdes", "export_sympy_odes", "extract_odes_from_mexfile"}: + from bionetgen.modelapi.sympy_odes import ( + SympyOdes, + export_sympy_odes, + extract_odes_from_mexfile, + ) + + return locals()[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "load", "run", + "bngmodel", + "sim_getter", + "BNGResult", + "SympyOdes", + "export_sympy_odes", + "extract_odes_from_mexfile", "BioNetGenModel", "SimResult", "ScanResult", diff --git a/python/bionetgen/__main__.py b/python/bionetgen/__main__.py new file mode 100644 index 00000000..ac98e5d2 --- /dev/null +++ b/python/bionetgen/__main__.py @@ -0,0 +1,6 @@ +"""Module entry point for ``python -m bionetgen``.""" + +from .cli import main + +if __name__ == "__main__": + main() diff --git a/python/bionetgen/_bionetgen_cpp.pyi b/python/bionetgen/_bionetgen_cpp.pyi index d88d7cd2..7effc830 100644 --- a/python/bionetgen/_bionetgen_cpp.pyi +++ b/python/bionetgen/_bionetgen_cpp.pyi @@ -178,6 +178,12 @@ class OdeOptions: max_step: float steady_state: bool steady_state_tol: float + stop_if: str + sample_times: List[float] + max_sim_steps: int + output_step_interval: int + sparse: bool + check_product_scale: float def __init__(self) -> None: ... # ─── Result Type ────────────────────────────────────────────────────────────── @@ -264,6 +270,15 @@ def simulate_ode( rtol: float = 1e-8, atol: float = 1e-8, method: str = "cvode", + max_step: float = 0.0, + steady_state: bool = False, + steady_state_tol: float = 1e-8, + stop_if: str = "", + sample_times: List[float] = [], + max_sim_steps: int = 0, + output_step_interval: int = 0, + sparse: bool = False, + check_product_scale: float = 0.0, ) -> Dict[str, object]: """Run ODE simulation on a generated network. @@ -285,6 +300,24 @@ def simulate_ode( Absolute tolerance. method : str Integration method: "cvode", "euler", or "rk4". + max_step : float + Maximum internal CVODE step size; zero disables the limit. + steady_state : bool + Stop when the derivative norm is below ``steady_state_tol``. + steady_state_tol : float + Derivative threshold for steady-state stopping. + stop_if : str + Expression evaluated at output points; nonzero stops the run. + sample_times : list[float] + Strictly increasing output times between ``t_start`` and ``t_end``. + max_sim_steps : int + Stochastic-only option; must be zero for ``simulate_ode``. + output_step_interval : int + Stochastic-only option; must be zero for ``simulate_ode``. + sparse : bool + Request the sparse CVODE linear solver. + check_product_scale : float + Warn when a species exceeds this threshold. Returns ------- @@ -301,6 +334,10 @@ def simulate_ssa( n_steps: int = 100, t_start: float = 0.0, seed: int = 0, + stop_if: str = "", + sample_times: List[float] = [], + max_sim_steps: int = 0, + output_step_interval: int = 0, ) -> Dict[str, object]: """Run SSA (stochastic) simulation on a generated network. @@ -318,6 +355,14 @@ def simulate_ssa( Start time. seed : int Random seed (0 = system default). + stop_if : str + Expression evaluated after each reaction; nonzero stops the run. + sample_times : list[float], optional + Strictly increasing output times between ``t_start`` and ``t_end``. + max_sim_steps : int + Maximum number of reaction events (0 = unlimited). + output_step_interval : int + Output every N reaction events when ``sample_times`` is not set. Returns ------- @@ -398,13 +443,16 @@ def simulate_nf( t_end: float = 100.0, n_steps: int = 100, seed: int = 0, - equilibrate: int = 0, + equilibrate: float = 0.0, verbose: bool = False, + source_path: str = "", + sample_times: List[float] = [], + traversal_limit: int = -1, ) -> Dict[str, object]: """Run network-free (NFSim) simulation on a model. - The model is serialized to XML and passed to the NFSim engine - for stochastic simulation without network enumeration. + The model is mapped directly to the NFSim engine for stochastic simulation + without network enumeration. XML is an explicit compatibility fallback. Parameters ---------- @@ -416,15 +464,24 @@ def simulate_nf( Number of output time steps. seed : int Random seed (0 = system default). - equilibrate : int + equilibrate : float Equilibration time before simulation (0 = none). verbose : bool Print progress information. + source_path : str + Path to the source BNGL file for resolving relative TFUN tables. + sample_times : list[float] + Strictly increasing output times between zero and ``t_end``. If the + final time is omitted, ``t_end`` is appended. + traversal_limit : int + NFsim bonded-neighborhood traversal depth. ``-1`` uses the model-derived + recommendation; non-negative values mirror NFsim's ``-utl`` control. Returns ------- dict - Keys: "time" (ndarray), "observables" (dict[str, ndarray]). + Keys: "time" (ndarray), "observables" (dict[str, ndarray]), and + "construction_path" ("direct", "in-memory-xml", or "on-disk-xml"). """ ... @@ -487,6 +544,22 @@ class io: """Write model to LaTeX format.""" ... + @staticmethod + def write_mex_string( + model: Model, + network: GeneratedNetwork, + t_start: float = 0.0, + t_end: float = 10.0, + n_steps: int = 20, + atol: float = 1e-6, + rtol: float = 1e-8, + max_num_steps: int = 2000, + max_step: float = 0.0, + sparse: bool = False, + ) -> str: + """Serialize a generated MEX ODE implementation.""" + ... + class viz: """Visualization graph writers exposed through the C++ extension.""" diff --git a/python/bionetgen/atomizer/libsbml2bngl.py b/python/bionetgen/atomizer/libsbml2bngl.py index fa448081..b5c2d981 100644 --- a/python/bionetgen/atomizer/libsbml2bngl.py +++ b/python/bionetgen/atomizer/libsbml2bngl.py @@ -31,7 +31,6 @@ # from biogrid import loadBioGridDict as loadBioGrid import logging -from bionetgen.atomizer.rulifier import postAnalysis import pprint import fnmatch from collections import defaultdict @@ -59,6 +58,26 @@ ) +def _read_sbml_document(path): + """Read an SBML path through libSBML's global string API. + + The Linux ``python-libsbml`` wheels can segfault in the SWIG + ``readSBMLFromFile`` wrapper on otherwise valid files. Reading the same + bytes in Python and using the global string entry point avoids both the + file-wrapper path and a second SWIG method wrapper that is unstable in + some Linux wheels. + """ + + with open(path, "rb") as handle: + payload = handle.read() + + encoding = "utf-8-sig" + declaration = re.search(rb"encoding\s*=\s*['\"]([^'\"]+)['\"]", payload[:256]) + if declaration is not None: + encoding = declaration.group(1).decode("ascii") + return libsbml.readSBMLFromString(payload.decode(encoding)) + + def loadBioGrid(): pass @@ -410,8 +429,7 @@ def extractCompartmentStatistics( Iterate over the translated species and check which compartments are used together, and how. """ - reader = libsbml.SBMLReader() - document = reader.readSBMLFromFile(bioNumber) + document = _read_sbml_document(bioNumber) parser = SBML2BNGL(document.getModel(), useID) database = structures.Databases() @@ -592,6 +610,11 @@ def reorderFunctions(functions): def postAnalysisHelper(outputFile, bngLocation, database): + # Import only for the optional context-analysis path. The normal SBML + # translation path does not need readBNGXML/lxml, and keeping this import + # lazy avoids loading a second native XML stack before libSBML is used. + from bionetgen.atomizer.rulifier import postAnalysis + consoleCommands.setBngExecutable(bngLocation) outputDir = os.sep.join(outputFile.split(os.sep)[:-1]) if outputDir != "": @@ -625,6 +648,8 @@ def postAnalyzeFile( """ Performs a postcreation file analysis based on context information """ + from bionetgen.atomizer.rulifier import postAnalysis + # print('Transforming generated BNG file to BNG-XML representation for analysis') postAnalysisHelper(outputFile, bngLocation, database) diff --git a/python/bionetgen/atomizer/modern/__init__.py b/python/bionetgen/atomizer/modern/__init__.py new file mode 100644 index 00000000..9f1bc996 --- /dev/null +++ b/python/bionetgen/atomizer/modern/__init__.py @@ -0,0 +1,735 @@ +"""Playground-derived SBML atomizer for BNG3. + +The implementation follows the public ``RuleWorld/bngplayground`` pipeline: +string-based SBML parsing, structured BNGL patterns, reaction classification, +species-composition inference, collision disambiguation, and canonical BNGL +writing. It is intentionally independent of the legacy atomizer modules so +the two paths can be compared during the migration. +""" + +from __future__ import annotations + +import math +import os +from collections import OrderedDict +from typing import Any, Dict, Iterable, Mapping, Optional + +from .core import ( + EditDistanceMatrixResult, + ModificationInference, + addToDependencyGraph, + add_to_dependency_graph, + analyzeNamingConventions, + analyze_naming_conventions, + analyzeReactions, + analyze_reactions, + buildSpeciesCompositionTable, + build_species_composition_table, + classifyReaction, + classify_reaction, + compute_weights, + defineEditDistanceMatrix, + define_edit_distance_matrix, + disambiguateCollidingSpecies, + disambiguate_colliding_species, + findLongestSubstring, + find_longest_substring, + getMoleculeTypes, + get_differences, + get_molecule_types, + get_seed_species, + getSeedSpecies, + infer_modification, + levenshtein, + reconcile_sct, + reconcileSCT, + similarity, + topological_sort, + topologicalSort, +) +from .parser import ( + SBMLParser, + extractGOTerms, + extractUniProtIds, + extract_go_terms, + extract_uniprot_ids, +) +from .multi import MultiParseResult, parse_multi_package +from .events import ( + EventActionsResult, + EventSet, + EventTranslationContext, + EventTranslationResult, + foldNumeric, + fold_numeric, + parseTimeThreshold, + parse_time_threshold, + synthesize_event_actions, +) +from .structures import ( + Action, + AtomicPatternResult, + Component, + Databases, + Molecule, + Rule, + Species, + States, + readFromString, + read_from_string, +) +from .types import * # noqa: F401,F403 +from .writer import ( + BNGLGenerationResult, + ReversibleRateSplit, + bnglFunction, + bnglReaction, + bngl_function, + bngl_reaction, + convert_math_expression, + curateParameters, + curate_parameters, + extend_function, + generateBNGL, + generate_bngl, + inlineSBMLFunctions, + inline_sbml_functions, + splitReversibleRate, + split_reversible_rate, + write_compartments, + write_functions, + write_molecule_types, + write_observables, + write_parameters, + write_reaction_rules, + write_seed_species, +) +from .units import apply_unit_scaling, resolve_unit_factor, unit_conversion_factor +from .helpers import ( + BindingException, + Counter, + CycleError, + DefaultDict, + LogMessage, + Logger, + Memoize, + MemoizeMapped, + TranslationException, + cleanParameterValue, + clean_parameter_value, + comb, + compareLists, + compare_lists, + convertMathFunction, + convert_math_function, + deepCopy, + deep_copy, + factorial, + isNode, + isValidBNGLName, + is_valid_bngl_name, + logMess, + log_mess, + logger, + longestCommonSubstring, + longest_common_substring, + pmemoize, + randInt, + rand_int, + sequenceMatcherRatio, + sequence_matcher_ratio, + setDifference, + setIntersection, + setUnion, + set_difference, + set_intersection, + set_union, + standardizeName, +) +from .annotation import ( + AnnotationStats, + ParsedAnnotation, + annotations_to_json, + annotations_to_yaml, + build_rdf_database, + compute_annotation_stats, + extract_uniprot_accessions, + find_equivalent_species, + get_all_annotations, + getAnnotationsByQualifier, + get_annotations_by_database, + get_annotations_by_qualifier, + get_canonical_species, + get_equivalence, + parse_resource_uri, + parse_species_annotations, +) +from .bng_xml import convertBNGXmlToBNGL, convert_bng_xml_to_bngl +from .rulifier import ( + StateTransition, + StateTransitionDiagram, + TransformationCenter, + TransformationContext, + analyze_rate_law, + build_state_transition_diagram, + collapse_redundant_rules, + extract_parameters, + extract_transformation_center, + find_redundant_rules, + group_by_reaction_center, + species_equal, +) +from .uniprot import ( + UniProtEntry, + clear_uniprot_cache, + fetch_uniprot_entry, +) + +DEFAULT_ATOMIZER_OPTIONS: Dict[str, Any] = { + "use_id": False, + "annotation": False, + "atomize": False, + "naming_conventions": DEFAULT_NAMING_CONVENTIONS, + "quiet_mode": False, + "log_level": "WARNING", + "actions": "", + "t_end": 10, + "n_steps": 100, +} + +_SOURCE_OPTION_ALIASES = { + "useId": "use_id", + "namingConventions": "naming_conventions", + "quietMode": "quiet_mode", + "logLevel": "log_level", + "tEnd": "t_end", + "nSteps": "n_steps", +} + + +def _normalize_options(options: Mapping[str, Any]) -> Dict[str, Any]: + """Normalize supported Playground option names at the Python boundary.""" + normalized = dict(options) + for source_name, python_name in _SOURCE_OPTION_ALIASES.items(): + if source_name in options: + if python_name not in options: + normalized[python_name] = options[source_name] + normalized.pop(source_name, None) + return normalized + + +class Atomizer: + """Convert SBML text to BNGL using the modern atomization pipeline.""" + + def __init__( + self, options: Optional[Mapping[str, Any]] = None, **kwargs: Any + ) -> None: + self.options = dict(DEFAULT_ATOMIZER_OPTIONS) + if options: + self.options.update(_normalize_options(options)) + self.options.update(_normalize_options(kwargs)) + self.parser = SBMLParser() + self.model = None + self.sct = None + self.databases = Databases() + self._configure_logger() + + def _configure_logger(self) -> None: + level = self.options.get("logLevel", self.options.get("log_level", "WARNING")) + quiet_mode = self.options.get( + "quietMode", self.options.get("quiet_mode", False) + ) + logger.setLevel(str(level).upper()) + logger.setQuietMode(bool(quiet_mode)) + + def set_options(self, options: Mapping[str, Any]) -> None: + self.options.update(_normalize_options(options)) + self._configure_logger() + + def get_options(self) -> Dict[str, Any]: + return dict(self.options) + + @staticmethod + def _threshold(name: str, default: float) -> float: + try: + return float(os.environ.get(name, str(default))) + except (TypeError, ValueError): + return float("nan") + + def _should_use_large_flat_fast_path(self, sbml_string: str) -> bool: + if self.options.get("atomize"): + return False + if os.environ.get("ATOMIZER_LARGE_FASTPATH", "1") == "0": + return False + min_species = self._threshold("ATOMIZER_FASTPATH_MIN_SPECIES", 1500) + min_reactions = self._threshold("ATOMIZER_FASTPATH_MIN_REACTIONS", 800) + min_sbml_chars = self._threshold("ATOMIZER_FASTPATH_MIN_SBML_CHARS", 5000000) + return ( + len(self.model.species) >= min_species + or len(self.model.reactions) >= min_reactions + or len(sbml_string) >= min_sbml_chars + ) + + @staticmethod + def _initial_amount_expression(species: SBMLSpecies) -> str: + try: + amount = float(species.initial_amount) + except (TypeError, ValueError): + amount = float("nan") + if math.isfinite(amount) and amount > 0: + return str(int(amount)) if amount.is_integer() else format(amount, ".15g") + + try: + concentration = float(species.initial_concentration) + except (TypeError, ValueError): + concentration = float("nan") + if math.isfinite(concentration) and concentration > 0: + compartment = standardizeName(species.compartment or "Compartment") + value = ( + str(int(concentration)) + if concentration.is_integer() + else format(concentration, ".15g") + ) + return f"({value} * __Avogadro__ * __compartment_{compartment}__)" + return "0" + + @staticmethod + def _build_large_flat_artifacts( + model: SBMLModel, + ) -> tuple[SpeciesCompositionTable, list[Molecule], list[SeedSpeciesEntry]]: + entries: Dict[str, SCTEntry] = OrderedDict() + dependencies: Dict[str, set] = OrderedDict() + reverse_dependencies: Dict[str, set] = OrderedDict() + sorted_species = [] + weights = [] + molecule_types: Dict[str, Molecule] = OrderedDict() + seed_species = [] + used_molecule_names = set() + + def unique_molecule_name(base: str, index: int) -> str: + if base not in used_molecule_names: + used_molecule_names.add(base) + return base + candidate = index + while f"{base}_{candidate}" in used_molecule_names: + candidate += 1 + name = f"{base}_{candidate}" + used_molecule_names.add(name) + return name + + for row, (species_id, source_species) in enumerate(model.species.items(), 1): + base_name = standardizeName( + species_id or source_species.name or f"sp_{row}" + ) + molecule_name = unique_molecule_name(base_name, row) + structure = Species() + structure.add_molecule(Molecule(molecule_name)) + structure.renumber_bonds() + + entries[species_id] = SCTEntry( + structure=structure, + components=[], + sbml_id=species_id, + is_elemental=True, + modifications={}, + weight=0, + bonds=[], + ) + dependencies[species_id] = set() + reverse_dependencies[species_id] = set() + sorted_species.append(species_id) + weights.append((species_id, 0)) + molecule_types.setdefault(molecule_name, Molecule(molecule_name)) + seed_species.append( + SeedSpeciesEntry( + species=structure.copy(), + concentration=Atomizer._initial_amount_expression(source_species), + compartment=source_species.compartment, + sbml_id=species_id, + ) + ) + + return ( + SpeciesCompositionTable( + entries=entries, + dependencies=dependencies, + reverse_dependencies=reverse_dependencies, + sorted_species=sorted_species, + weights=weights, + ), + list(molecule_types.values()), + seed_species, + ) + + def atomize(self, sbml_string: str) -> AtomizerResult: + try: + logger.info("ATM003", "Parsing SBML model...") + self.model = self.parser.parse(sbml_string) + logger.info( + "ATM004", + f'Model "{self.model.name}": {len(self.model.species)} species, ' + f"{len(self.model.reactions)} reactions", + ) + if self._should_use_large_flat_fast_path(sbml_string): + logger.warning( + "ATM011", + f"Large-model flat fast path enabled " + f"(species={len(self.model.species)}, " + f"reactions={len(self.model.reactions)}, " + f"sbmlChars={len(sbml_string)})", + ) + self.sct, molecule_types, seed_species = ( + self._build_large_flat_artifacts(self.model) + ) + bngl, observable_map = generate_bngl( + self.model, + self.sct, + molecule_types, + seed_species, + atomize=False, + actions=str(self.options.get("actions", "") or ""), + t_end=float(self.options.get("t_end", 10) or 10), + n_steps=int(self.options.get("n_steps", 100) or 100), + ) + return AtomizerResult( + bngl=bngl, + database=self.databases, + annotation=None, + observable_map=observable_map, + log=logger.getMessages(), + success=True, + ) + logger.info("ATM005", "Building species composition table...") + self.sct = build_species_composition_table( + self.model, + use_id=bool(self.options.get("use_id", False)), + use_annotations=bool(self.options.get("annotation", False)), + atomize=bool(self.options.get("atomize", False)), + naming_conventions=self.options.get( + "naming_conventions", self.options.get("naming_patterns") + ), + ) + disambiguated = disambiguate_colliding_species(self.sct, self.model) + if disambiguated > 0: + logger.info( + "ATM008", + f"Disambiguated {disambiguated} colliding species (isoform collapse)", + ) + molecule_types = get_molecule_types(self.sct) + logger.info("ATM006", f"Found {len(molecule_types)} molecule types") + reconcile_sct(self.sct, molecule_types) + seed_species = get_seed_species(self.sct, self.model) + logger.info("ATM007", f"Found {len(seed_species)} seed species") + logger.info("ATM008", "Generating BNGL model...") + bngl, observable_map = generate_bngl( + self.model, + self.sct, + molecule_types, + seed_species, + atomize=bool(self.options.get("atomize", False)), + actions=str(self.options.get("actions", "") or ""), + t_end=float(self.options.get("t_end", 10) or 10), + n_steps=int(self.options.get("n_steps", 100) or 100), + ) + logger.info("ATM009", "BNGL generation complete") + annotation = ( + self._annotation_data() if self.options.get("annotation") else None + ) + return AtomizerResult( + bngl=bngl, + database=self.databases, + annotation=annotation, + observable_map=observable_map, + log=logger.getMessages(), + success=True, + ) + except Exception as exc: + logger.error("ATM010", f"Atomization failed: {exc}") + return AtomizerResult( + bngl="", + database=self.databases, + annotation=None, + observable_map={}, + log=logger.getMessages(), + success=False, + error=str(exc), + ) + + def flat_translation(self, sbml_string: str) -> AtomizerResult: + previous = self.options.get("atomize", False) + self.options["atomize"] = False + try: + return self.atomize(sbml_string) + finally: + self.options["atomize"] = previous + + def full_atomization(self, sbml_string: str) -> AtomizerResult: + previous = self.options.get("atomize", False) + self.options["atomize"] = True + try: + return self.atomize(sbml_string) + finally: + self.options["atomize"] = previous + + def get_model(self): + return self.model + + def get_sct(self): + return self.sct + + def get_databases(self) -> Databases: + return self.databases + + def get_uniprot_ids(self, species_id: str) -> list: + if self.model is None or species_id not in self.model.species: + return [] + resources = [ + resource + for annotation in self.model.species[species_id].annotations + for resource in annotation.resources + ] + return extract_uniprot_ids(resources) + + def analyze_naming(self): + if self.model is None: + return None + return analyze_naming_conventions( + [item.name or item_id for item_id, item in self.model.species.items()], + self.options.get("naming_conventions", self.options.get("naming_patterns")), + ) + + def analyze_reaction_patterns(self): + return analyze_reactions(self.model) if self.model is not None else None + + def clear(self) -> None: + self.model = None + self.sct = None + self.databases = Databases() + logger.clear() + + def _annotation_data(self) -> Dict[str, Any]: + if self.model is None: + return {} + return { + "species": { + species_id: { + "name": species.name, + "annotations": [ + { + "type": annotation.qualifier_type, + "biologicalQualifier": annotation.biological_qualifier, + "modelQualifier": annotation.model_qualifier, + "resources": list(annotation.resources), + } + for annotation in species.annotations + ], + } + for species_id, species in self.model.species.items() + if species.annotations + }, + "reactions": {}, + "compartments": { + compartment_id: { + "name": compartment.name, + "dimensions": compartment.spatial_dimensions, + "size": compartment.size, + } + for compartment_id, compartment in self.model.compartments.items() + }, + } + + +# Preserve the public method spellings from the TypeScript Atomizer facade +# alongside BNG3's snake_case Python API. +Atomizer.setOptions = Atomizer.set_options +Atomizer.getOptions = Atomizer.get_options +Atomizer.flatTranslation = Atomizer.flat_translation +Atomizer.fullAtomization = Atomizer.full_atomization +Atomizer.getModel = Atomizer.get_model +Atomizer.getSCT = Atomizer.get_sct +Atomizer.getUniProtIds = Atomizer.get_uniprot_ids +Atomizer.getDatabases = Atomizer.get_databases +Atomizer.analyzeNaming = Atomizer.analyze_naming +Atomizer.analyzeReactionPatterns = Atomizer.analyze_reaction_patterns + + +def sbml_to_bngl( + sbml_string: str, options: Optional[Mapping[str, Any]] = None, **kwargs: Any +) -> AtomizerResult: + return Atomizer(options, **kwargs).atomize(sbml_string) + + +def sbml_to_bngl_flat( + sbml_string: str, options: Optional[Mapping[str, Any]] = None, **kwargs: Any +) -> AtomizerResult: + return Atomizer(options, atomize=False, **kwargs).flat_translation(sbml_string) + + +def sbml_to_bngl_atomized( + sbml_string: str, options: Optional[Mapping[str, Any]] = None, **kwargs: Any +) -> AtomizerResult: + return Atomizer(options, atomize=True, **kwargs).full_atomization(sbml_string) + + +# Preserve the TypeScript convenience-function spellings for direct callers. +sbmlToBngl = sbml_to_bngl +sbmlToBnglFlat = sbml_to_bngl_flat +sbmlToBnglAtomized = sbml_to_bngl_atomized + + +__all__ = [ + "Action", + "Atomizer", + "AtomicPatternResult", + "AnnotationStats", + "BindingException", + "BNGLGenerationResult", + "BNGL_LEXER_KEYWORDS", + "Component", + "Counter", + "CycleError", + "DefaultDict", + "DEFAULT_ATOMIZER_OPTIONS", + "DEFAULT_NAMING_CONVENTIONS", + "Databases", + "EditDistanceMatrixResult", + "EventActionsResult", + "EventSet", + "EventTranslationContext", + "EventTranslationResult", + "LogMessage", + "Logger", + "Memoize", + "MemoizeMapped", + "Molecule", + "ModificationInference", + "NamingConventions", + "MultiParseResult", + "ParsedAnnotation", + "Rule", + "ReversibleRateSplit", + "SBMLParser", + "Species", + "States", + "StateTransition", + "StateTransitionDiagram", + "TransformationCenter", + "TransformationContext", + "TranslationException", + "UniProtEntry", + "analyze_naming_conventions", + "analyzeNamingConventions", + "analyze_reactions", + "analyzeReactions", + "analyze_rate_law", + "annotations_to_json", + "annotations_to_yaml", + "apply_unit_scaling", + "addToDependencyGraph", + "add_to_dependency_graph", + "build_state_transition_diagram", + "build_species_composition_table", + "buildSpeciesCompositionTable", + "cleanParameterValue", + "clean_parameter_value", + "convertBNGXmlToBNGL", + "convert_bng_xml_to_bngl", + "bngl_function", + "bnglFunction", + "bnglReaction", + "bngl_reaction", + "curateParameters", + "curate_parameters", + "classify_reaction", + "classifyReaction", + "clear_uniprot_cache", + "comb", + "compareLists", + "compare_lists", + "compute_annotation_stats", + "collapse_redundant_rules", + "convertMathFunction", + "convert_math_function", + "deepCopy", + "deep_copy", + "defineEditDistanceMatrix", + "define_edit_distance_matrix", + "disambiguate_colliding_species", + "disambiguateCollidingSpecies", + "extend_function", + "extract_go_terms", + "extractGOTerms", + "extractUniProtIds", + "extract_uniprot_accessions", + "extract_uniprot_ids", + "extract_parameters", + "extract_transformation_center", + "factorial", + "find_equivalent_species", + "findLongestSubstring", + "find_longest_substring", + "find_redundant_rules", + "fold_numeric", + "foldNumeric", + "fetch_uniprot_entry", + "generate_bngl", + "generateBNGL", + "inlineSBMLFunctions", + "inline_sbml_functions", + "get_molecule_types", + "getMoleculeTypes", + "get_all_annotations", + "getAnnotationsByQualifier", + "get_annotations_by_database", + "get_annotations_by_qualifier", + "get_canonical_species", + "get_equivalence", + "get_seed_species", + "getSeedSpecies", + "group_by_reaction_center", + "build_rdf_database", + "isNode", + "isValidBNGLName", + "is_valid_bngl_name", + "levenshtein", + "logMess", + "log_mess", + "logger", + "longestCommonSubstring", + "longest_common_substring", + "pmemoize", + "randInt", + "rand_int", + "read_from_string", + "readFromString", + "resolve_unit_factor", + "reconcileSCT", + "parse_time_threshold", + "parseTimeThreshold", + "parse_multi_package", + "parse_resource_uri", + "parse_species_annotations", + "sbml_to_bngl", + "sbml_to_bngl_atomized", + "sbml_to_bngl_flat", + "sbmlToBngl", + "sbmlToBnglAtomized", + "sbmlToBnglFlat", + "species_equal", + "splitReversibleRate", + "split_reversible_rate", + "sequenceMatcherRatio", + "sequence_matcher_ratio", + "setDifference", + "setIntersection", + "setUnion", + "set_difference", + "set_intersection", + "set_union", + "similarity", + "topological_sort", + "topologicalSort", + "synthesize_event_actions", + "standardizeName", + "unit_conversion_factor", +] diff --git a/python/bionetgen/atomizer/modern/annotation.py b/python/bionetgen/atomizer/modern/annotation.py new file mode 100644 index 00000000..881e890c --- /dev/null +++ b/python/bionetgen/atomizer/modern/annotation.py @@ -0,0 +1,412 @@ +"""Annotation parsing and lightweight MIRIAM helpers for modern Atomizer. + +The public RuleWorld/bngplayground annotation parser is the source contract +for this module. Network-backed ontology resolution remains outside this +pure data layer; callers can use the existing optional legacy resolver when +they explicitly need remote enrichment. +""" + +from __future__ import annotations + +import json +import re +from collections import OrderedDict +from dataclasses import dataclass, field +from typing import Dict, Iterable, List, Mapping, Optional, Tuple + +from .types import AnnotationInfo, SBMLModel, SBMLSpecies + +BIOLOGICAL_QUALIFIER_NAMES = { + 0: "BQB_IS", + 1: "BQB_HAS_PART", + 2: "BQB_IS_PART_OF", + 3: "BQB_IS_VERSION_OF", + 4: "BQB_HAS_VERSION", + 5: "BQB_IS_HOMOLOG_TO", + 6: "BQB_IS_DESCRIBED_BY", + 7: "BQB_IS_ENCODED_BY", + 8: "BQB_ENCODES", + 9: "BQB_OCCURS_IN", + 10: "BQB_HAS_PROPERTY", + 11: "BQB_IS_PROPERTY_OF", + 12: "BQB_HAS_TAXON", + 13: "BQB_UNKNOWN", +} + +MODEL_QUALIFIER_NAMES = { + 0: "BQM_IS", + 1: "BQM_IS_DESCRIBED_BY", + 2: "BQM_IS_DERIVED_FROM", + 3: "BQM_IS_INSTANCE_OF", + 4: "BQM_HAS_INSTANCE", + 5: "BQM_UNKNOWN", +} + + +@dataclass(frozen=True) +class ParsedAnnotation: + species_id: str + species_name: str + qualifier_type: str + qualifier: str + resources: List[str] + database: str + identifier: str + + +@dataclass +class AnnotationStats: + total_species: int + annotated_species: int + annotation_count: int + database_distribution: Dict[str, int] = field(default_factory=dict) + qualifier_distribution: Dict[str, int] = field(default_factory=dict) + coverage_percent: float = 0.0 + + +_DATABASE_PATTERNS: Tuple[Tuple[str, re.Pattern[str]], ...] = ( + ("uniprot", re.compile(r"uniprot[:/]([A-Z0-9]+)", re.IGNORECASE)), + ("go", re.compile(r"GO[:/](\d+)", re.IGNORECASE)), + ("chebi", re.compile(r"CHEBI[:/](\d+)", re.IGNORECASE)), + ( + "kegg", + re.compile( + r"kegg\.(compound|reaction|pathway)[:/]([A-Za-z0-9]+)", re.IGNORECASE + ), + ), + ("reactome", re.compile(r"reactome[:/]([A-Z0-9_]+)", re.IGNORECASE)), + ("biomodels", re.compile(r"biomodels\.db[:/](BIOMD\d+)", re.IGNORECASE)), + ("pubmed", re.compile(r"pubmed[:/](\d+)", re.IGNORECASE)), + ("doi", re.compile(r"doi[:/](10\.\d+/[^\s]+)", re.IGNORECASE)), + ("obo", re.compile(r"obo\.([A-Z]+)[:/]([A-Z0-9_:]+)", re.IGNORECASE)), + ("ncbiTaxon", re.compile(r"taxonomy[:/](\d+)", re.IGNORECASE)), + ("ensembl", re.compile(r"ensembl[:/]([A-Z0-9]+)", re.IGNORECASE)), + ("interpro", re.compile(r"interpro[:/](IPR\d+)", re.IGNORECASE)), + ("pfam", re.compile(r"pfam[:/](PF\d+)", re.IGNORECASE)), + ("ec", re.compile(r"ec-code[:/](\d+\.\d+\.\d+\.\d+)", re.IGNORECASE)), +) + + +def parse_resource_uri(uri: str) -> Tuple[str, str]: + """Return ``(database, identifier)`` using reference parser precedence.""" + + resource = str(uri) + for database, pattern in _DATABASE_PATTERNS: + match = pattern.search(resource) + if match: + # Keep the public reference behavior for multi-capture patterns: + # the first capture is the identifier. + return database, match.group(1) or ( + match.group(2) if match.lastindex else "" + ) + + identifiers_match = re.search( + r"identifiers\.org/([^/]+)/([^/\s]+)", resource, re.IGNORECASE + ) + if identifiers_match: + return identifiers_match.group(1), identifiers_match.group(2) + + generic_match = re.search(r"/([^/]+)/([^/\s]+)$", resource) + if generic_match: + return "unknown", generic_match.group(2) + + return "unknown", resource + + +def parse_species_annotations(species: SBMLSpecies) -> List[ParsedAnnotation]: + """Expand each SBML CV-term resource into a normalized annotation.""" + + result: List[ParsedAnnotation] = [] + for annotation in species.annotations: + if annotation.qualifier_type == 1: + qualifier_type = "biological" + qualifier = BIOLOGICAL_QUALIFIER_NAMES.get( + ( + annotation.biological_qualifier + if annotation.biological_qualifier is not None + else 13 + ), + "BQB_UNKNOWN", + ) + else: + qualifier_type = "model" + qualifier = MODEL_QUALIFIER_NAMES.get( + ( + annotation.model_qualifier + if annotation.model_qualifier is not None + else 5 + ), + "BQM_UNKNOWN", + ) + + for resource in annotation.resources: + database, identifier = parse_resource_uri(resource) + result.append( + ParsedAnnotation( + species_id=species.id, + species_name=species.name, + qualifier_type=qualifier_type, + qualifier=qualifier, + resources=[resource], + database=database, + identifier=identifier, + ) + ) + return result + + +def get_all_annotations(model: SBMLModel) -> "OrderedDict[str, List[ParsedAnnotation]]": + result: "OrderedDict[str, List[ParsedAnnotation]]" = OrderedDict() + for species_id, species in model.species.items(): + annotations = parse_species_annotations(species) + if annotations: + result[species_id] = annotations + return result + + +def get_annotations_by_database( + model: SBMLModel, database: str +) -> "OrderedDict[str, List[ParsedAnnotation]]": + result: "OrderedDict[str, List[ParsedAnnotation]]" = OrderedDict() + for species_id, species in model.species.items(): + annotations = [ + annotation + for annotation in parse_species_annotations(species) + if annotation.database.lower() == database.lower() + ] + if annotations: + result[species_id] = annotations + return result + + +def get_annotations_by_qualifier( + model: SBMLModel, qualifier_type: str, qualifier: Optional[str] = None +) -> "OrderedDict[str, List[ParsedAnnotation]]": + result: "OrderedDict[str, List[ParsedAnnotation]]" = OrderedDict() + for species_id, species in model.species.items(): + annotations = [ + annotation + for annotation in parse_species_annotations(species) + if annotation.qualifier_type == qualifier_type + and (qualifier is None or annotation.qualifier == qualifier) + ] + if annotations: + result[species_id] = annotations + return result + + +def get_annotations_by_qualifier_resources( + annotations: Iterable[AnnotationInfo], qualifier: int, is_biological: bool = True +) -> List[str]: + """Return raw resources matching a Playground qualifier enum value.""" + + result: List[str] = [] + for annotation in annotations: + if ( + is_biological + and annotation.qualifier_type == 1 + and annotation.biological_qualifier == qualifier + ) or ( + not is_biological + and annotation.qualifier_type == 0 + and annotation.model_qualifier == qualifier + ): + result.extend(annotation.resources) + return result + + +def find_equivalent_species(model: SBMLModel) -> "OrderedDict[str, List[str]]": + """Group species sharing BQB_IS or BQB_IS_VERSION_OF resources.""" + + groups: "OrderedDict[str, List[str]]" = OrderedDict() + for species_id, species in model.species.items(): + for annotation in parse_species_annotations(species): + if annotation.qualifier not in {"BQB_IS", "BQB_IS_VERSION_OF"}: + continue + key = f"{annotation.database}:{annotation.identifier}" + members = groups.setdefault(key, []) + if species_id not in members: + members.append(species_id) + + return OrderedDict( + (key, members) for key, members in groups.items() if len(members) > 1 + ) + + +def get_canonical_species( + equivalence_map: Mapping[str, Iterable[str]], model: SBMLModel +) -> Dict[str, str]: + """Map non-canonical members to shortest-name species in each group.""" + + result: Dict[str, str] = {} + for members in equivalence_map.values(): + member_list = list(members) + if not member_list: + continue + canonical = min( + member_list, + key=lambda species_id: ( + len( + model.species.get(species_id, SBMLSpecies(species_id)).name + or species_id + ), + model.species.get(species_id, SBMLSpecies(species_id)).name + or species_id, + ), + ) + for species_id in member_list: + if species_id != canonical: + result[species_id] = canonical + return result + + +def build_rdf_database( + model: SBMLModel, filter_string: Optional[Iterable[str]] = None +) -> "OrderedDict[str, List[str]]": + """Group species by raw RDF resource, sorted by display-name length.""" + + filters = list(filter_string or []) + database: "OrderedDict[str, List[str]]" = OrderedDict() + for species_id, species in model.species.items(): + for annotation in parse_species_annotations(species): + resource = annotation.resources[0] + if filters and not any(item in resource for item in filters): + continue + members = database.setdefault(resource, []) + if species_id not in members: + members.append(species_id) + + for resource, members in database.items(): + members.sort( + key=lambda species_id: len( + model.species.get(species_id, SBMLSpecies(species_id)).name + or species_id + ) + ) + return database + + +def get_equivalence( + species_id: str, rdf_database: Mapping[str, List[str]] +) -> List[str]: + for members in rdf_database.values(): + if species_id not in members: + continue + return [] if members.index(species_id) == 0 else [members[0]] + return [] + + +def compute_annotation_stats(model: SBMLModel) -> AnnotationStats: + annotated_species = 0 + annotation_count = 0 + database_distribution: Dict[str, int] = {} + qualifier_distribution: Dict[str, int] = {} + + for species in model.species.values(): + annotations = parse_species_annotations(species) + if not annotations: + continue + annotated_species += 1 + annotation_count += len(annotations) + for annotation in annotations: + database_distribution[annotation.database] = ( + database_distribution.get(annotation.database, 0) + 1 + ) + qualifier_distribution[annotation.qualifier] = ( + qualifier_distribution.get(annotation.qualifier, 0) + 1 + ) + + total_species = len(model.species) + return AnnotationStats( + total_species=total_species, + annotated_species=annotated_species, + annotation_count=annotation_count, + database_distribution=database_distribution, + qualifier_distribution=qualifier_distribution, + coverage_percent=( + (annotated_species / total_species * 100) if total_species else 0.0 + ), + ) + + +def extract_uniprot_accessions(model: SBMLModel) -> Dict[str, List[str]]: + return { + species_id: [ + annotation.identifier + for annotation in parse_species_annotations(species) + if annotation.database == "uniprot" + ] + for species_id, species in model.species.items() + if any( + annotation.database == "uniprot" + for annotation in parse_species_annotations(species) + ) + } + + +def annotations_to_yaml( + model: SBMLModel, annotation_map: Mapping[str, Iterable[ParsedAnnotation]] +) -> str: + lines: List[str] = [] + for species_id, annotations in annotation_map.items(): + species = model.species.get(species_id) + name = (species.name if species is not None else "") or species_id + lines.extend([f"{species_id}:", f" name: {name}", " annotations:"]) + for annotation in annotations: + resource = annotation.resources[0] if annotation.resources else "" + lines.extend( + [ + f" - qualifier: {annotation.qualifier}", + f" database: {annotation.database}", + f" identifier: {annotation.identifier}", + f" uri: {resource}", + ] + ) + return "\n".join(lines) + + +def annotations_to_json( + model: SBMLModel, annotation_map: Mapping[str, Iterable[ParsedAnnotation]] +) -> str: + data: Dict[str, object] = {} + for species_id, annotations in annotation_map.items(): + species = model.species.get(species_id) + name = (species.name if species is not None else "") or species_id + data[species_id] = { + "name": name, + "annotations": [ + { + "qualifier": annotation.qualifier, + "database": annotation.database, + "identifier": annotation.identifier, + "uri": annotation.resources[0] if annotation.resources else "", + } + for annotation in annotations + ], + } + return json.dumps(data, indent=2, ensure_ascii=False) + + +__all__ = [ + "AnnotationStats", + "ParsedAnnotation", + "annotations_to_json", + "annotations_to_yaml", + "build_rdf_database", + "compute_annotation_stats", + "extract_uniprot_accessions", + "find_equivalent_species", + "getAnnotationsByQualifier", + "get_all_annotations", + "get_annotations_by_database", + "get_annotations_by_qualifier", + "get_annotations_by_qualifier_resources", + "get_canonical_species", + "get_equivalence", + "parse_resource_uri", + "parse_species_annotations", +] + + +# Keep the exact public name exported by the TypeScript parser. +getAnnotationsByQualifier = get_annotations_by_qualifier_resources diff --git a/python/bionetgen/atomizer/modern/bng_xml.py b/python/bionetgen/atomizer/modern/bng_xml.py new file mode 100644 index 00000000..c75cd829 --- /dev/null +++ b/python/bionetgen/atomizer/modern/bng_xml.py @@ -0,0 +1,292 @@ +"""Convert BioNetGen's BNG-SBML extension back to BNGL. + +This is the Python counterpart of the public Playground fallback converter. +It intentionally handles BNG-specific ``ListOf*`` sections in addition to +ordinary SBML and keeps the conversion dependency-free. +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from typing import Dict, Iterable, List, Optional + +from .helpers import logger + + +def _local_name(tag: object) -> str: + return str(tag).rsplit("}", 1)[-1] + + +def _descendants(element: Optional[ET.Element], *names: str) -> List[ET.Element]: + if element is None: + return [] + wanted = {name.lower() for name in names} + return [item for item in element.iter() if _local_name(item.tag).lower() in wanted] + + +def _first(element: Optional[ET.Element], *names: str) -> Optional[ET.Element]: + values = _descendants(element, *names) + return values[0] if values else None + + +def _attribute(element: ET.Element, *names: str, default: str = "") -> str: + for name in names: + value = element.get(name) + if value is not None and value != "": + return value + return default + + +def _text(element: Optional[ET.Element]) -> str: + if element is None: + return "" + return "".join(element.itertext()).strip() + + +def _escape_name(value: str) -> str: + return re.sub(r"\s+", " ", value).strip() + + +def _pattern_to_string(pattern: ET.Element, product: bool = False) -> str: + bond_indices: Dict[str, int] = {} + next_bond = 1 + bonds = _descendants(_first(pattern, "ListOfBonds"), "Bond") + for bond in bonds: + site1 = _attribute(bond, "site1") + site2 = _attribute(bond, "site2") + if not site1 or not site2: + continue + key = f"{site1}__{site2}" + index = bond_indices.setdefault(key, next_bond) + if index == next_bond: + next_bond += 1 + bond_indices[f"{site2}__{site1}"] = index + + molecule_strings: List[str] = [] + for molecule in _descendants(pattern, "Molecule"): + molecule_name = _attribute(molecule, "name") + compartment = _attribute(molecule, "compartment") + component_strings: List[str] = [] + for component in _descendants(molecule, "Component"): + component_name = _attribute(component, "name") + component_id = _attribute(component, "id") + state = _attribute(component, "state") + value = component_name + (f"~{state}" if state else "") + + bond_index: Optional[int] = None + for bond in bonds: + site1 = _attribute(bond, "site1") + site2 = _attribute(bond, "site2") + if component_id not in {site1, site2}: + continue + partner = site2 if site1 == component_id else site1 + bond_index = bond_indices.get( + f"{component_id}__{partner}", + bond_indices.get(f"{partner}__{component_id}"), + ) + if bond_index is not None: + break + + if bond_index is not None: + value += f"!{bond_index}" + else: + number_of_bonds = _attribute(component, "numberOfBonds") + direct_bond = _attribute(component, "bond") + if "+" in number_of_bonds or "+" in direct_bond: + value += "!+" + elif "?" in number_of_bonds or "?" in direct_bond: + value += "!?" + elif direct_bond and direct_bond != "0": + value += f"!{direct_bond}" + if component_name: + component_strings.append(value) + + base = ( + f"{molecule_name}({','.join(component_strings)})" + if component_strings + else f"{molecule_name}()" + ) + molecule_strings.append(f"{base}@{compartment}" if compartment else base) + + separator = "." if product else " + " + return separator.join(molecule_strings) + + +def _reaction_rate( + reaction_rule: ET.Element, + parameter_names: Iterable[str], +) -> str: + rate_law = _first(reaction_rule, "RateLaw") + if rate_law is None: + return "" + + rate_type = _attribute(rate_law, "type") + rate_constants = _descendants(rate_law, "RateConstant") + if rate_type.lower() == "ele": + return ( + _attribute(rate_constants[0], "value", default=_text(rate_constants[0])) + if rate_constants + else "" + ) + if rate_type.lower() == "function": + return _attribute(rate_law, "name") + if rate_type in {"MM", "Sat", "Hill", "Arrhenius"}: + arguments = [ + _attribute(item, "value", default=_text(item)) or "0" + for item in rate_constants + ] + if rate_type in {"MM", "Sat"} and len(arguments) >= 2: + reactant_list = _first(reaction_rule, "ListOfReactantPatterns") + reactant_molecule = _first(reactant_list, "Molecule") + compartment = ( + _attribute(reactant_molecule, "compartment") + if reactant_molecule is not None + else "" + ) + if compartment: + scale = compartment + if "NA" in set(parameter_names): + scale += " * NA" + arguments[1] = f"({arguments[1]} * {scale})" + logger.info( + "BNGXML002", + f"Scaled {rate_type} constant for {compartment}: {arguments[1]}", + ) + return f"{rate_type}({','.join(arguments)})" + return ( + _attribute(rate_constants[0], "value", default=_text(rate_constants[0])) + if rate_constants + else "" + ) + + +def convert_bng_xml_to_bngl(xml: str) -> str: + """Convert BNG-SBML XML text into a BNGL model string.""" + + root = ET.fromstring(xml) + model = _first(root, "model") + if model is None: + raise ValueError("No element found in SBML") + + lines: List[str] = [] + + parameters = _descendants(_first(model, "ListOfParameters"), "Parameter") + parameter_names = {_attribute(item, "id", "name") for item in parameters} + if parameters: + lines.append("begin parameters") + for parameter in parameters: + identifier = _attribute(parameter, "id", "name", default="p") + value = _attribute( + parameter, + "expr", + "value", + default="", + ) + lines.append(f" {identifier} {value}") + lines.extend(["end parameters", ""]) + + compartments = _descendants( + _first(model, "ListOfCompartments"), "Compartment", "compartment" + ) + if compartments: + lines.append("begin compartments") + for compartment in compartments: + identifier = _attribute(compartment, "id", "name", default="c") + size = _attribute(compartment, "size", default="1") + lines.append(f" {identifier} {size}") + lines.extend(["end compartments", ""]) + + molecule_type_list = _first(model, "ListOfMoleculeTypes") + molecule_types = _descendants(molecule_type_list, "MoleculeType") + if molecule_types: + lines.append("begin molecule types") + for molecule_type in molecule_types: + identifier = _attribute(molecule_type, "id", default="M") + component_strings: List[str] = [] + for component in _descendants(molecule_type, "ComponentType"): + name = _attribute(component, "id", "name", default="c") + states = [ + _attribute(state, "id", default=_text(state)) + for state in _descendants(component, "AllowedState") + ] + component_strings.append( + f"{name}~{'~'.join(state for state in states if state)}" + if any(states) + else name + ) + lines.append(f" {identifier}({','.join(component_strings)})") + lines.extend(["end molecule types", ""]) + + species_list = _first(model, "ListOfSpecies") + species = _descendants(species_list, "Species") + if species: + lines.append("begin seed species") + for item in species: + name = _attribute(item, "name", "id") + compartment = _attribute(item, "compartment") + concentration = _attribute( + item, + "concentration", + "initialConcentration", + "initialAmount", + default="0", + ) + full_name = f"{name}@{compartment}" if compartment else name + lines.append(f" {_escape_name(full_name)} {concentration}") + lines.extend(["end seed species", ""]) + + observable_list = _first(model, "ListOfObservables") + observables = _descendants(observable_list, "Observable") + if observables: + lines.append("begin observables") + for observable in observables: + observable_type = _attribute(observable, "type", default="Molecules") + name = _attribute(observable, "name", "id", default="obs") + pattern = _first(observable, "Pattern") + pattern_string = _pattern_to_string(pattern) if pattern is not None else "" + lines.append(f" {observable_type} {name} {pattern_string}") + lines.extend(["end observables", ""]) + + function_list = _first(model, "ListOfFunctions") + functions = _descendants(function_list, "Function") + if functions: + lines.append("begin functions") + for function in functions: + name = _attribute(function, "id", "name", default="f") + expression = _first(function, "Expression", "math") + lines.append(f" function {name} = {_text(expression)}") + lines.extend(["end functions", ""]) + + rule_list = _first(model, "ListOfReactionRules") + reaction_rules = _descendants(rule_list, "ReactionRule") + if reaction_rules: + lines.append("begin reaction rules") + for reaction_rule in reaction_rules: + reactant_list = _first(reaction_rule, "ListOfReactantPatterns") + product_list = _first(reaction_rule, "ListOfProductPatterns") + reactants = [ + _pattern_to_string(pattern) + for pattern in _descendants(reactant_list, "ReactantPattern") + ] + products = [ + _pattern_to_string(pattern, product=True) + for pattern in _descendants(product_list, "ProductPattern") + ] + reactant_string = " + ".join(reactants) + product_string = " + ".join(products) + rate = _reaction_rate(reaction_rule, parameter_names) + lines.append(f" {reactant_string} -> {product_string} {rate}") + lines.extend(["end reaction rules", ""]) + + bngl = "\n".join(lines) + logger.info("BNGXML001", "Converted BNG SBML to BNGL (fallback)") + return bngl + + +# Preserve source-facing spelling for callers porting directly from the +# TypeScript reference while keeping snake_case as BNG3's Python API. +convertBNGXmlToBNGL = convert_bng_xml_to_bngl + + +__all__ = ["convertBNGXmlToBNGL", "convert_bng_xml_to_bngl"] diff --git a/python/bionetgen/atomizer/modern/core.py b/python/bionetgen/atomizer/modern/core.py new file mode 100644 index 00000000..03fd8405 --- /dev/null +++ b/python/bionetgen/atomizer/modern/core.py @@ -0,0 +1,912 @@ +"""Reaction analysis and species-composition inference. + +This is a focused Python port of the Playground atomizer's +``atomization/core.ts``. The dependency graph is intentionally explicit: a +species may only inherit structure from a species that has already been +resolved, and unresolved relationships remain elemental rather than being +silently guessed. +""" + +from __future__ import annotations + +import json +import os +import re +from collections import OrderedDict +from dataclasses import dataclass +from typing import ( + Dict, + Iterable, + Iterator, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, + Union, +) + +from .structures import Component, Molecule, Species, read_from_string +from .types import ( + DEFAULT_NAMING_CONVENTIONS, + NamingConventions, + ReactionPattern, + SBMLModel, + SBMLReaction, + SBMLSpecies, + SCTEntry, + SeedSpeciesEntry, + SpeciesCompositionTable, + get_kinetic_math, + standardize_name, +) + +_DEP_CYCLE_LOG_LIMIT = int(os.environ.get("ATOMIZER_DEP_CYCLE_LOG_LIMIT", "20")) +_dep_cycle_log_count = 0 + +NamingPatternConfig = Union[ + Mapping[Tuple[str, ...], str], + NamingConventions, +] + + +def _resolve_naming_patterns( + patterns: Optional[NamingPatternConfig], +) -> Mapping[Tuple[str, ...], str]: + if patterns is None: + return DEFAULT_NAMING_CONVENTIONS.patterns + if isinstance(patterns, NamingConventions): + return patterns.patterns + return patterns + + +def _log_dependency_cycle(message: str) -> None: + global _dep_cycle_log_count + from .helpers import logger + + if _DEP_CYCLE_LOG_LIMIT < 0 or _dep_cycle_log_count < _DEP_CYCLE_LOG_LIMIT: + logger.warning("DEP001", message) + elif _dep_cycle_log_count == _DEP_CYCLE_LOG_LIMIT: + logger.warning("DEP001", "Additional dependency cycle logs suppressed.") + _dep_cycle_log_count += 1 + + +def levenshtein(left: str, right: str) -> int: + if left == right: + return 0 + if not left: + return len(right) + if not right: + return len(left) + previous = list(range(len(left) + 1)) + for row, right_char in enumerate(right, 1): + current = [row] + for column, left_char in enumerate(left, 1): + current.append( + min( + current[-1] + 1, + previous[column] + 1, + previous[column - 1] + (left_char != right_char), + ) + ) + previous = current + return previous[-1] + + +def similarity(left: str, right: str) -> float: + size = max(len(left), len(right)) + return 1.0 if size == 0 else 1.0 - levenshtein(left, right) / size + + +def add_to_dependency_graph( + dependency_graph: Dict[str, List[str]], + label: str, + value: Union[str, Sequence[str]], +) -> None: + """Add one or more unique dependency labels, matching the reference API.""" + + dependencies = dependency_graph.setdefault(label, []) + values = [value] if isinstance(value, str) else value + for dependency in values: + if dependency and dependency not in dependencies: + dependencies.append(dependency) + + +def longest_common_substring(left: str, right: str) -> str: + if not left or not right: + return "" + previous = [0] * (len(right) + 1) + longest = 0 + end = 0 + for left_index, left_char in enumerate(left, 1): + current = [0] + for right_index, right_char in enumerate(right, 1): + value = previous[right_index - 1] + 1 if left_char == right_char else 0 + current.append(value) + if value > longest: + longest = value + end = left_index + previous = current + return left[end - longest : end] + + +def find_longest_substring(left: str, right: str) -> str: + """Source-compatible name for the longest-common-substring helper.""" + + return longest_common_substring(left, right) + + +def get_differences(shorter: str, longer: str) -> List[str]: + differences: List[str] = [] + left = right = 0 + while left < len(shorter) and right < len(longer): + if shorter[left] == longer[right]: + left += 1 + right += 1 + elif shorter[left] in longer[right:]: + differences.append(f"+ {longer[right]}") + right += 1 + else: + differences.append(f"- {shorter[left]}") + left += 1 + while right < len(longer): + differences.append(f"+ {longer[right]}") + right += 1 + return differences + + +@dataclass(frozen=True) +class EditDistanceMatrixResult: + """Reference-shaped result with a compatibility iterator for old callers.""" + + matrix: List[List[int]] + pairs: List[Tuple[str, str]] + differences: List[List[str]] + + def __iter__(self) -> Iterator[object]: + """Keep the pre-port two-value unpacking contract working.""" + + yield self.pairs + yield self.differences + + +@dataclass(frozen=True) +class ModificationInference: + """Named result returned by the Playground modification heuristic.""" + + base: Optional[str] + modification: Optional[str] + confidence: float + + def __iter__(self) -> Iterator[object]: + """Keep the original Python tuple-unpacking contract working.""" + + yield self.base + yield self.modification + yield self.confidence + + +def define_edit_distance_matrix( + species_names: Sequence[str], similarity_threshold: int = 4 +) -> EditDistanceMatrixResult: + matrix = [[0 for _ in species_names] for _ in species_names] + pairs: List[Tuple[str, str]] = [] + differences: List[List[str]] = [] + for index, left in enumerate(species_names): + for right_index, right in enumerate(species_names[index + 1 :], index + 1): + if abs(len(left) - len(right)) > similarity_threshold: + continue + distance = levenshtein(left, right) + matrix[index][right_index] = distance + matrix[right_index][index] = distance + if 0 < distance <= similarity_threshold: + shorter, longer = ( + (left, right) if len(left) <= len(right) else (right, left) + ) + pairs.append((shorter, longer)) + differences.append(get_differences(shorter, longer)) + return EditDistanceMatrixResult(matrix, pairs, differences) + + +def analyze_naming_conventions( + species_names: Sequence[str], + patterns: Optional[NamingPatternConfig] = None, + similarity_threshold: int = 4, +) -> Dict[str, object]: + conventions = _resolve_naming_patterns(patterns) + distance_result = define_edit_distance_matrix(species_names, similarity_threshold) + pairs, differences = distance_result + pair_classification: Dict[str, List[Tuple[str, str]]] = OrderedDict() + keys: List[str] = [] + for pair, difference in zip(pairs, differences): + key = json.dumps(difference, ensure_ascii=False, separators=(",", ":")) + if key not in keys: + keys.append(key) + modification = conventions.get(tuple(difference)) + if modification: + pair_classification.setdefault(modification, []).append(pair) + from .helpers import logger + + logger.info( + "NAM001", + f"Naming analysis: {len(pairs)} similar pairs, " + f"{len(pair_classification)} classifications", + ) + return { + "pairClassification": pair_classification, + "keys": keys, + "patterns": conventions, + } + + +def infer_modification( + species_name: str, + base_species: Sequence[str], + patterns: Optional[NamingPatternConfig] = None, +) -> ModificationInference: + conventions = _resolve_naming_patterns(patterns) + candidates = [ + candidate for candidate in base_species if len(candidate) < len(species_name) + ] + best = ModificationInference(None, None, 0.0) + for candidate in candidates: + modification = conventions.get(tuple(get_differences(candidate, species_name))) + if modification: + confidence = similarity(candidate, species_name) + if confidence > best.confidence: + best = ModificationInference(candidate, modification, confidence) + if best.confidence > 0: + return best + + for candidate in candidates: + common = longest_common_substring(candidate, species_name) + confidence = len(common) / max(len(candidate), len(species_name)) + if confidence <= max(best.confidence, 0.5): + continue + suffix = species_name[len(candidate) :] + modification = None + if re.fullmatch(r"_?[pP]+", suffix): + modification = ( + "Double-Phosphorylation" + if len(suffix.replace("_", "")) > 1 + else "Phosphorylation" + ) + elif re.fullmatch(r"_?[aA]ct?", suffix): + modification = "Activation" + elif re.fullmatch(r"_?[uU]b?", suffix): + modification = "Ubiquitination" + elif suffix.startswith("_"): + modification = "Binding" + if modification: + best = ModificationInference(candidate, modification, confidence) + return best + + +def _reaction_species(references: Iterable[object]) -> List[str]: + result = [] + for reference in references: + species = getattr(reference, "species", None) + if species is None and isinstance(reference, Mapping): + species = reference.get("species") + if species and species != "EmptySet": + result.append(str(species)) + return result + + +def _has_saturation_rate(reaction: SBMLReaction) -> bool: + math = get_kinetic_math(reaction.kinetic_law).lower() + if any(token in math for token in ("sat", "mm", "hill")): + return True + return "/" in math and "+" in math and math.count("*") >= 2 + + +def classify_reaction(reaction: SBMLReaction) -> ReactionPattern: + reactants = _reaction_species(reaction.reactants) + products = _reaction_species(reaction.products) + modifiers = [ + str(getattr(modifier, "species", modifier)) for modifier in reaction.modifiers + ] + common = [species for species in reactants if species in products] + + if not reactants and products: + return ReactionPattern("synthesis", [], products, modifiers) + if reactants and not products: + return ReactionPattern("degradation", reactants, [], modifiers) + if ( + len(reactants) == 2 + and len(products) == 1 + and len(common) == 1 + and _has_saturation_rate(reaction) + ): + catalyst = common[0] + substrate = next(species for species in reactants if species != catalyst) + return ReactionPattern( + "catalysis", [substrate], [substrate], [catalyst] + modifiers, catalyst + ) + if len(reactants) >= 2 and len(products) == 1: + return ReactionPattern("binding", reactants, products, modifiers) + if len(reactants) == 1 and len(products) >= 2 and len(common) == 1: + common_species = common[0] + return ReactionPattern( + "synthesis", + [], + [species for species in products if species != common_species], + [common_species] + modifiers, + common_species, + ) + if len(reactants) == 1 and len(products) >= 2: + return ReactionPattern("unbinding", reactants, products, modifiers) + if len(reactants) == 1 and len(products) == 1: + return ReactionPattern("modification", reactants, products, modifiers) + if len(reactants) == 2 and len(products) == 2 and len(common) == 1: + common_species = common[0] + return ReactionPattern( + "catalysis", + [species for species in reactants if species != common_species], + [species for species in products if species != common_species], + modifiers, + common_species, + ) + return ReactionPattern("transformation", reactants, products, modifiers) + + +def analyze_reactions(model: SBMLModel) -> Dict[str, object]: + binding: Dict[str, List[str]] = OrderedDict() + modification: Dict[str, str] = OrderedDict() + dependencies: Dict[str, Set[str]] = OrderedDict() + patterns: Dict[str, ReactionPattern] = OrderedDict() + + for reaction_id, reaction in model.reactions.items(): + pattern = classify_reaction(reaction) + patterns[reaction_id] = pattern + if pattern.type == "binding" and len(pattern.products) == 1: + product = pattern.products[0] + binding[product] = list(pattern.reactants) + dependencies.setdefault(product, set()).update(pattern.reactants) + elif pattern.type == "unbinding" and len(pattern.reactants) == 1: + complex_id = pattern.reactants[0] + complex_name = ( + model.species.get(complex_id, SBMLSpecies(complex_id)).name + or complex_id + ) + if all( + (model.species.get(product, SBMLSpecies(product)).name or product) + in complex_name + or complex_name + in (model.species.get(product, SBMLSpecies(product)).name or product) + for product in pattern.products + ): + binding.setdefault(complex_id, list(pattern.products)) + dependencies.setdefault(complex_id, set()).update(pattern.products) + elif ( + pattern.type in {"modification", "catalysis"} + and pattern.reactants + and pattern.products + ): + base = pattern.reactants[0] + derived = pattern.products[0] + base_name = model.species.get(base, SBMLSpecies(base)).name or base + derived_name = ( + model.species.get(derived, SBMLSpecies(derived)).name or derived + ) + if base_name in derived_name or len(derived_name) > len(base_name): + modification[derived] = base + dependencies.setdefault(derived, set()).add(base) + from .helpers import logger + + logger.info( + "RXN001", + f"Reaction analysis: {len(binding)} binding, " + f"{len(modification)} modification", + ) + return { + "bindingReactions": binding, + "modificationReactions": modification, + "dependencies": dependencies, + "patterns": patterns, + } + + +def topological_sort( + species_ids: Sequence[str], dependencies: Mapping[str, Set[str]] +) -> List[str]: + sorted_species: List[str] = [] + visited: Set[str] = set() + visiting: Set[str] = set() + + def visit(species_id: str, path: Optional[List[str]] = None) -> None: + if species_id in visited: + return + if species_id in visiting: + _log_dependency_cycle( + f"Dependency cycle detected: {' -> '.join(path or [])} -> {species_id}" + ) + return + visiting.add(species_id) + current_path = [*(path or []), species_id] + for dependency in dependencies.get(species_id, set()): + if dependency in species_ids: + visit(dependency, current_path) + visiting.remove(species_id) + visited.add(species_id) + sorted_species.append(species_id) + + for species_id in species_ids: + visit(species_id) + return sorted_species + + +def compute_weights( + species_ids: Sequence[str], dependencies: Mapping[str, Set[str]] +) -> Dict[str, int]: + weights: Dict[str, int] = {} + for species_id in topological_sort(species_ids, dependencies): + weights[species_id] = 1 + sum( + weights.get(dependency, 1) + for dependency in dependencies.get(species_id, set()) + ) + return weights + + +def _sanitize_structure(species: Species) -> None: + for molecule in species.molecules: + molecule.name = standardize_name(molecule.name) + molecule.compartment = ( + standardize_name(molecule.compartment) if molecule.compartment else "" + ) + for component in molecule.components: + component.name = standardize_name(component.name) + component.states = [ + ( + state + if state in ("+", "?") + or re.fullmatch(r"-?\d+(?:\.\d+)?", state or "") + else standardize_name(state) + ) + for state in component.states + ] + if component.active_state: + component.active_state = standardize_name(component.active_state) + + +def _apply_name_compartments(name: str, species: Species) -> None: + prefix = re.match(r"^@([^:]+)::", name or "") + species_compartment = prefix.group(1) if prefix else "" + pattern = name[prefix.end() :] if prefix else name + molecules = pattern.split(".") + for index, molecule in enumerate(species.molecules[: len(molecules)]): + match = re.search(r"@([^@.]+)$", molecules[index]) + molecule.compartment = match.group(1) if match else species_compartment + + +def _elemental_species(sbml_species: SBMLSpecies) -> Species: + name = sbml_species.name or sbml_species.id + name_without_compartment = re.sub(r"^@[^:]+::", "", name) + try: + parsed = read_from_string(name_without_compartment) + if parsed.molecules and any( + molecule.components for molecule in parsed.molecules + ): + _sanitize_structure(parsed) + for molecule in parsed.molecules: + molecule.idx = sbml_species.id + return parsed + except (TypeError, ValueError): + pass + result = Species() + result.add_molecule(Molecule(standardize_name(sbml_species.id), sbml_species.id)) + return result + + +def _complex_species( + component_ids: Sequence[str], entries: Mapping[str, SCTEntry], use_id: bool = False +) -> Species: + result = Species() + copied_groups: List[List[Molecule]] = [] + for index, component_id in enumerate(component_ids): + entry = entries.get(component_id) + if entry is not None: + component_species = entry.structure.copy() + component_species.update_bonds(result.get_bond_numbers()) + for molecule in component_species.molecules: + if index > 0 and not molecule.contains(f"b{index}"): + molecule.add_component( + Component(f"b{index}", f"{molecule.idx}_b{index}") + ) + if index < len(component_ids) - 1 and not molecule.contains( + f"b{index + 1}" + ): + molecule.add_component( + Component(f"b{index + 1}", f"{molecule.idx}_b{index + 1}") + ) + copied_groups.append(component_species.molecules) + for molecule in component_species.molecules: + result.add_molecule(molecule) + else: + name = component_id if use_id else standardize_name(component_id) + molecule = Molecule(name, component_id) + if index > 0: + molecule.add_component( + Component(f"b{index}", f"{molecule.idx}_b{index}") + ) + if index < len(component_ids) - 1: + molecule.add_component( + Component(f"b{index + 1}", f"{molecule.idx}_b{index + 1}") + ) + copied_groups.append([molecule]) + result.add_molecule(molecule) + + for index in range(len(copied_groups) - 1): + left = copied_groups[index][0] + right = copied_groups[index + 1][0] + bond = max(result.get_bond_numbers() or [0]) + 1 + left_site = left.get_component(f"b{index + 1}") + right_site = right.get_component(f"b{index + 1}") + if left_site is not None and right_site is not None: + left_site.add_bond(bond) + right_site.add_bond(bond) + result.bonds.append((left_site.idx, right_site.idx)) + result.renumber_bonds() + return result + + +def _modification_state(modification: str) -> str: + return { + "Phosphorylation": "P", + "Double-Phosphorylation": "PP", + "Triple-Phosphorylation": "PPP", + "Dephosphorylation": "0", + "Ubiquitination": "Ub", + "Deubiquitination": "0", + "Acetylation": "Ac", + "Deacetylation": "0", + "Methylation": "Me", + "Demethylation": "0", + "Activation": "A", + "Inactivation": "I", + "Binding": "bound", + "Localization": "loc", + "Dimerization": "dim", + "Trimerization": "trim", + }.get(modification, modification[:1].upper()) + + +def _modified_species( + base: Optional[SCTEntry], modification: str, fallback: SBMLSpecies +) -> Species: + if base is None: + return _elemental_species(fallback) + result = base.structure.copy() + if not result.molecules: + return result + molecule = result.molecules[0] + component_name = modification.lower() + component = molecule.get_component(component_name) + state = _modification_state(modification) + if component is None: + component = Component(component_name, f"{molecule.idx}_{component_name}") + component.add_state("0") + component.add_state(state) + component.set_active_state(state) + molecule.add_component(component) + else: + component.set_active_state(state) + return result + + +def build_species_composition_table( + model: SBMLModel, + *, + use_id: bool = False, + use_annotations: bool = True, + atomize: bool = False, + naming_patterns: Optional[NamingPatternConfig] = None, + naming_conventions: Optional[NamingConventions] = None, + **_: object, +) -> SpeciesCompositionTable: + species_ids = list(model.species.keys()) + relationships = analyze_reactions(model) + binding = relationships["bindingReactions"] + modifications = relationships["modificationReactions"] + dependencies: Dict[str, Set[str]] = OrderedDict() + for species_id, deps in relationships["dependencies"].items(): + dependencies.setdefault(species_id, set()).update(deps) + + names_to_ids: Dict[str, str] = OrderedDict() + for species_id in species_ids: + item = model.species[species_id] + if item.name and item.name not in names_to_ids: + names_to_ids[item.name] = species_id + names_to_ids.setdefault(species_id, species_id) + + naming_config = ( + naming_conventions if naming_conventions is not None else naming_patterns + ) + naming = analyze_naming_conventions( + [model.species[species_id].name or species_id for species_id in species_ids], + naming_config, + ) + for pairs in naming["pairClassification"].values(): + for base_name, derived_name in pairs: + base_id = names_to_ids.get(base_name) + derived_id = names_to_ids.get(derived_name) + if base_id and derived_id and base_id != derived_id: + dependencies.setdefault(derived_id, set()).add(base_id) + + reverse: Dict[str, Set[str]] = OrderedDict() + for species_id, deps in dependencies.items(): + for dependency in deps: + reverse.setdefault(dependency, set()).add(species_id) + + sorted_species = topological_sort(species_ids, dependencies) + weights = compute_weights(species_ids, dependencies) + entries: Dict[str, SCTEntry] = OrderedDict() + + for species_id in sorted_species: + item = model.species[species_id] + item_name = item.name or species_id + is_elemental = True + components: List[str] = [] + modifications_info: Dict[str, str] = {} + + has_real_structure = ("." in item_name or "(" in item_name) and "!" in item_name + if atomize and has_real_structure: + structure = read_from_string(item_name) + _sanitize_structure(structure) + components = structure.get_molecule_names() + is_elemental = False + elif atomize and species_id in binding: + components = list(binding[species_id]) + is_elemental = False + structure = _complex_species(components, entries, use_id) + elif atomize and species_id in modifications: + base_id = modifications[species_id] + components = [base_id] + is_elemental = False + base_name = model.species.get(base_id, SBMLSpecies(base_id)).name or base_id + _, modification, _confidence = infer_modification( + item_name, [base_name], naming_config + ) + if modification: + modifications_info["state"] = modification + structure = _modified_species(entries.get(base_id), modification, item) + else: + structure = _elemental_species(item) + elif atomize and dependencies.get(species_id): + dependency_ids = list(dependencies[species_id]) + components = dependency_ids + is_elemental = False + if len(dependency_ids) == 1: + base_id = dependency_ids[0] + base_name = ( + model.species.get(base_id, SBMLSpecies(base_id)).name or base_id + ) + _, modification, _confidence = infer_modification( + item_name, [base_name], naming_config + ) + if modification: + modifications_info["state"] = modification + structure = _modified_species( + entries.get(base_id), modification, item + ) + else: + structure = _complex_species(dependency_ids, entries, use_id) + else: + structure = _complex_species(dependency_ids, entries, use_id) + else: + structure = _elemental_species(item) + + _apply_name_compartments(item_name, structure) + declared = { + standardize_name(compartment_id) for compartment_id in model.compartments + } + if item.compartment and standardize_name(item.compartment) in declared: + for molecule in structure.molecules: + if ( + molecule.compartment + and standardize_name(molecule.compartment) not in declared + ): + molecule.compartment = item.compartment + entries[species_id] = SCTEntry( + structure=structure, + components=components, + sbml_id=species_id, + is_elemental=is_elemental, + modifications=modifications_info, + weight=weights.get(species_id, 1), + ) + + elemental_count = sum(entry.is_elemental for entry in entries.values()) + from .helpers import logger + + logger.info( + "SCT001", + f"Built SCT: {len(entries)} species " + f"({elemental_count} elemental, {len(entries) - elemental_count} complex)", + ) + table = SpeciesCompositionTable( + entries=entries, + dependencies=dependencies, + reverse_dependencies=reverse, + sorted_species=sorted_species, + weights=sorted(weights.items(), key=lambda item: item[1]), + ) + reconcile_sct(table, get_molecule_types(table)) + return table + + +def _update_molecule_type(existing: Molecule, incoming: Molecule) -> None: + existing_by_name: Dict[str, List[Component]] = OrderedDict() + incoming_by_name: Dict[str, List[Component]] = OrderedDict() + for component in existing.components: + existing_by_name.setdefault(component.name, []).append(component) + for component in incoming.components: + incoming_by_name.setdefault(component.name, []).append(component) + for name, incoming_components in incoming_by_name.items(): + current = existing_by_name.setdefault(name, []) + for component in incoming_components[len(current) :]: + copied = component.copy() + existing.add_component(copied) + current.append(copied) + for left, right in zip(current, incoming_components): + left.add_states(right.states, update=False) + + +def get_molecule_types(sct: SpeciesCompositionTable) -> List[Molecule]: + molecule_types: Dict[str, Molecule] = OrderedDict() + for entry in sct.entries.values(): + for molecule in entry.structure.molecules: + if molecule.name not in molecule_types: + molecule_types[molecule.name] = molecule.copy() + else: + _update_molecule_type(molecule_types[molecule.name], molecule) + return list(molecule_types.values()) + + +def reconcile_sct( + sct: SpeciesCompositionTable, molecule_types: Sequence[Molecule] +) -> None: + type_map = {molecule.name: molecule for molecule in molecule_types} + for entry in sct.entries.values(): + for molecule in entry.structure.molecules: + template = type_map.get(molecule.name) + if template is None: + continue + counts = {} + templates = {} + for component in template.components: + counts[component.name] = counts.get(component.name, 0) + 1 + templates.setdefault(component.name, component) + present = {} + for component in molecule.components: + present[component.name] = present.get(component.name, 0) + 1 + for name, count in counts.items(): + for _ in range(max(0, count - present.get(name, 0))): + component = templates[name].copy() + component.bonds = [] + component.set_active_state( + "0" + if "0" in component.states + else (component.states[0] if component.states else "") + ) + molecule.add_component(component) + + +def disambiguate_colliding_species( + sct: SpeciesCompositionTable, model: SBMLModel +) -> int: + groups: Dict[Tuple[str, str], List[str]] = OrderedDict() + for species_id, entry in sct.entries.items(): + if not entry.structure.molecules: + continue + pattern = ".".join( + sorted(molecule.to_string(True) for molecule in entry.structure.molecules) + ) + compartment = model.species.get(species_id, SBMLSpecies(species_id)).compartment + groups.setdefault((compartment, pattern), []).append(species_id) + count = 0 + for species_ids in groups.values(): + if len(set(species_ids)) < 2: + continue + for species_id in species_ids: + entry = sct.entries[species_id] + discriminator = Component("__sp", states=[standardize_name(species_id)]) + discriminator.set_active_state(standardize_name(species_id)) + entry.structure.molecules[0].add_component(discriminator) + count += 1 + return count + + +def get_seed_species( + sct: SpeciesCompositionTable, model: SBMLModel +) -> List[SeedSpeciesEntry]: + initial_assignments = {item.symbol: item.math for item in model.initial_assignments} + result: List[SeedSpeciesEntry] = [] + for species_id, entry in sct.entries.items(): + item = model.species[species_id] + compartment = standardize_name(item.compartment or "Compartment") + volume = f"__compartment_{compartment}__" + amount_set = ( + item.initial_amount_set + if item.initial_amount_set is not None + else item.initial_amount != 0 + ) + concentration_set = ( + item.initial_concentration_set + if item.initial_concentration_set is not None + else item.initial_concentration != 0 + ) + if species_id in initial_assignments: + expression = initial_assignments[species_id] + if not item.has_only_substance_units: + expression = f"({expression} * __Avogadro__ * {volume})" + else: + expression = f"({expression})" + elif amount_set: + expression = ( + str(item.initial_amount).rstrip("0").rstrip(".") + if "." in str(item.initial_amount) + else str(item.initial_amount) + ) + elif concentration_set: + expression = f"({item.initial_concentration} * __Avogadro__ * {volume})" + else: + expression = "0" + result.append( + SeedSpeciesEntry( + entry.structure.copy(), expression, item.compartment, species_id + ) + ) + return result + + +# Keep the public names used by the TypeScript reference available alongside +# BNG3's snake_case spelling. +addToDependencyGraph = add_to_dependency_graph +analyzeNamingConventions = analyze_naming_conventions +analyzeReactions = analyze_reactions +buildSpeciesCompositionTable = build_species_composition_table +classifyReaction = classify_reaction +defineEditDistanceMatrix = define_edit_distance_matrix +disambiguateCollidingSpecies = disambiguate_colliding_species +findLongestSubstring = find_longest_substring +getMoleculeTypes = get_molecule_types +getSeedSpecies = get_seed_species +reconcileSCT = reconcile_sct +topologicalSort = topological_sort + + +__all__ = [ + "EditDistanceMatrixResult", + "ModificationInference", + "addToDependencyGraph", + "add_to_dependency_graph", + "analyzeNamingConventions", + "analyze_naming_conventions", + "analyzeReactions", + "analyze_reactions", + "buildSpeciesCompositionTable", + "build_species_composition_table", + "classifyReaction", + "classify_reaction", + "compute_weights", + "defineEditDistanceMatrix", + "define_edit_distance_matrix", + "disambiguateCollidingSpecies", + "disambiguate_colliding_species", + "get_differences", + "getMoleculeTypes", + "findLongestSubstring", + "find_longest_substring", + "getSeedSpecies", + "get_molecule_types", + "get_seed_species", + "infer_modification", + "levenshtein", + "reconcile_sct", + "reconcileSCT", + "similarity", + "topologicalSort", + "topological_sort", +] diff --git a/python/bionetgen/atomizer/modern/events.py b/python/bionetgen/atomizer/modern/events.py new file mode 100644 index 00000000..8f3d3e3d --- /dev/null +++ b/python/bionetgen/atomizer/modern/events.py @@ -0,0 +1,483 @@ +"""Safe SBML event translation for the Playground-derived atomizer. + +The simulation engine has no general trigger executor. The Playground port +therefore translates only fixed-time, constant-valued events into BNGL action +phases and records every other event as an explicit diagnostic. +""" + +from __future__ import annotations + +import math +import re +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Callable, List, Optional, Sequence, Tuple + +from .types import SBMLEvent +from .types import standardize_name + + +@dataclass +class EventTranslationContext: + """Callbacks and simulation defaults needed for event translation.""" + + resolve_species_pattern: Callable[[str], Optional[str]] + resolve_param: Callable[[str], Optional[float]] + is_param: Callable[[str], bool] + method: str = "ode" + base_t_end: float = 10 + base_steps: int = 100 + + @property + def resolveSpeciesPattern(self): + return self.resolve_species_pattern + + @resolveSpeciesPattern.setter + def resolveSpeciesPattern(self, value): + self.resolve_species_pattern = value + + @property + def resolveParam(self): + return self.resolve_param + + @resolveParam.setter + def resolveParam(self, value): + self.resolve_param = value + + @property + def isParam(self): + return self.is_param + + @isParam.setter + def isParam(self, value): + self.is_param = value + + @property + def baseTEnd(self): + return self.base_t_end + + @baseTEnd.setter + def baseTEnd(self, value): + self.base_t_end = value + + @property + def baseSteps(self): + return self.base_steps + + @baseSteps.setter + def baseSteps(self, value): + self.base_steps = value + + +@dataclass +class EventSet: + kind: str + target: str + value: float + variable: str + + +@dataclass +class EventTranslationResult: + actions_block: Optional[str] + converted: int + untranslated: List[Tuple[SBMLEvent, str]] + + @property + def actionsBlock(self): + return self.actions_block + + @actionsBlock.setter + def actionsBlock(self, value): + self.actions_block = value + + +EventActionsResult = EventTranslationResult + + +def _tokenize(expression: str) -> Optional[List[str]]: + token_pattern = re.compile( + r"\s*(" + r"[A-Za-z_][A-Za-z0-9_]*|" + r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|" + r"[()+\-*/^])" + ) + tokens: List[str] = [] + position = 0 + while position < len(expression): + match = token_pattern.match(expression, position) + if match is None: + if expression[position:].strip(): + return None + break + tokens.append(match.group(1)) + position = match.end() + return tokens + + +class _NumericParser: + def __init__( + self, tokens: Sequence[str], resolve: Callable[[str], Optional[float]] + ) -> None: + self.tokens = list(tokens) + self.resolve = resolve + self.position = 0 + + def peek(self) -> Optional[str]: + if self.position >= len(self.tokens): + return None + return self.tokens[self.position] + + def take(self) -> Optional[str]: + token = self.peek() + if token is not None: + self.position += 1 + return token + + def parse(self) -> Optional[float]: + value = self.parse_expression() + if value is None or self.position != len(self.tokens): + return None + return value if math.isfinite(value) else None + + def parse_expression(self) -> Optional[float]: + left = self.parse_term() + if left is None: + return None + while self.peek() in {"+", "-"}: + operator = self.take() + right = self.parse_term() + if right is None: + return None + left = left + right if operator == "+" else left - right + return left + + def parse_term(self) -> Optional[float]: + left = self.parse_factor() + if left is None: + return None + while self.peek() in {"*", "/"}: + operator = self.take() + right = self.parse_factor() + if right is None or (operator == "/" and right == 0): + return None + left = left * right if operator == "*" else left / right + return left + + def parse_factor(self) -> Optional[float]: + base = self.parse_unary() + if base is None: + return None + if self.peek() == "^": + self.take() + exponent = self.parse_factor() + if exponent is None: + return None + try: + base = base**exponent + except (OverflowError, ValueError): + return None + return base if math.isfinite(base) else None + + def parse_unary(self) -> Optional[float]: + if self.peek() == "+": + self.take() + return self.parse_unary() + if self.peek() == "-": + self.take() + value = self.parse_unary() + return -value if value is not None else None + return self.parse_primary() + + def parse_primary(self) -> Optional[float]: + token = self.peek() + if token is None: + return None + if token == "(": + self.take() + value = self.parse_expression() + if value is None or self.take() != ")": + return None + return value + if re.fullmatch(r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", token): + self.take() + value = float(token) + return value if math.isfinite(value) else None + if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", token): + # Function calls are intentionally not folded. This keeps the + # action translation fail-closed for unknown or dynamic functions. + if ( + self.position + 1 < len(self.tokens) + and self.tokens[self.position + 1] == "(" + ): + return None + self.take() + value = self.resolve(token) + return value if value is not None and math.isfinite(value) else None + return None + + +def fold_numeric( + expression: str, resolve: Callable[[str], Optional[float]] +) -> Optional[float]: + """Evaluate a small constant arithmetic language without ``eval``.""" + + if not expression or not expression.strip(): + return None + tokens = _tokenize(expression) + if not tokens: + return None + return _NumericParser(tokens, resolve).parse() + + +def _strip_outer_parens(value: str) -> str: + result = value.strip() + while result.startswith("(") and result.endswith(")"): + depth = 0 + balanced = True + for index, character in enumerate(result): + if character == "(": + depth += 1 + elif character == ")": + depth -= 1 + if depth == 0 and index != len(result) - 1: + balanced = False + break + if not balanced or depth != 0: + break + result = result[1:-1].strip() + return result + + +def _drop_unmatched_trailing_paren(value: str) -> str: + result = value.strip() + while result.endswith(")") and result.count(")") > result.count("("): + result = result[:-1].rstrip() + return result + + +def _balanced_inner(value: str) -> str: + depth = 0 + for index, character in enumerate(value): + if character == "(": + depth += 1 + elif character == ")": + if depth == 0: + return value[:index].strip() + depth -= 1 + return value.strip() + + +def parse_time_threshold(trigger: str) -> Optional[str]: + """Return the threshold from a simple ``time >= threshold`` trigger.""" + + if not trigger: + return None + value = trigger.strip() + match = re.match(r"^(?:geq|gt)\s*\(\s*time\s*,\s*(.+)\)\s*$", value, re.IGNORECASE) + if match: + return _strip_outer_parens(_balanced_inner(match.group(1))) + match = re.match( + r"^(?:leq|lt)\s*\(\s*(.+?)\s*,\s*time\s*\)\s*$", + value, + re.IGNORECASE, + ) + if match: + return _strip_outer_parens(match.group(1)) + match = re.match( + r"^\(?\s*time\s*(?:>=|>)\s*(.+?)\s*\)?$", + value, + re.IGNORECASE, + ) + if match: + return _strip_outer_parens(_drop_unmatched_trailing_paren(match.group(1))) + match = re.match( + r"^\(?\s*(.+?)\s*(?:<=|<)\s*time\s*\)?$", + value, + re.IGNORECASE, + ) + if match: + return _strip_outer_parens(_drop_unmatched_trailing_paren(match.group(1))) + return None + + +def _event_assignment(assignment: object) -> Tuple[str, str]: + if isinstance(assignment, Mapping): + return str(assignment.get("variable", "")), str(assignment.get("math", "")) + if isinstance(assignment, (tuple, list)) and len(assignment) >= 2: + return str(assignment[0]), str(assignment[1]) + return "", "" + + +def _format_number(value: float) -> str: + if float(value).is_integer(): + return str(int(value)) + return str(float(f"{value:.12g}")) + + +def _render_set(kind: str, target: str, value: float) -> str: + command = "setConcentration" if kind == "conc" else "setParameter" + return f'{command}("{target}", "{_format_number(value)}")' + + +def synthesize_event_actions( + events: Sequence[SBMLEvent], context: EventTranslationContext +) -> EventTranslationResult: + """Translate safe fixed-time events and report all rejected events.""" + + untranslated: List[Tuple[SBMLEvent, str]] = [] + scheduled: List[Tuple[float, List[Tuple[str, str, float]], float]] = [] + + for event in events: + threshold = parse_time_threshold(event.trigger) + if threshold is None: + untranslated.append( + ( + event, + "trigger is not a simple time threshold (state-dependent " + "triggers cannot be scheduled)", + ) + ) + continue + time = fold_numeric(threshold, context.resolve_param) + if time is None: + untranslated.append( + ( + event, + f'trigger time "{threshold}" does not reduce to a constant', + ) + ) + continue + if event.delay: + delay = fold_numeric(event.delay, context.resolve_param) + if delay is None: + untranslated.append( + ( + event, + f'delay "{event.delay}" is not constant', + ) + ) + continue + time += delay + + sets: List[Tuple[str, str, float]] = [] + failure: Optional[str] = None + for assignment in event.assignments: + variable, expression = _event_assignment(assignment) + value = fold_numeric(expression, context.resolve_param) + if value is None: + failure = ( + f'assignment "{variable} := {expression}" is not constant ' + "(depends on species/time or a function)" + ) + break + pattern = context.resolve_species_pattern(variable) + if pattern: + sets.append(("conc", pattern, value)) + elif context.is_param(variable): + sets.append(("param", standardize_name(variable), value)) + else: + failure = ( + f' assignment target "{variable}" is neither a known species ' + "nor a parameter" + ).lstrip() + break + if failure is not None: + untranslated.append((event, failure)) + continue + + priority = 0.0 + if getattr(event, "priority", None): + folded_priority = fold_numeric(event.priority or "", context.resolve_param) + if folded_priority is not None: + priority = folded_priority + scheduled.append((time, sets, priority)) + + if not scheduled: + return EventTranslationResult(None, 0, untranslated) + + scheduled.sort(key=lambda item: (item[0], -item[2])) + merged: List[Tuple[float, List[Tuple[str, str, float]]]] = [] + for time, sets, _priority in scheduled: + if merged and abs(merged[-1][0] - time) < 1e-12: + merged[-1][1].extend(sets) + else: + merged.append((time, list(sets))) + + last_fire = merged[-1][0] + t_final = ( + context.base_t_end if context.base_t_end > last_fire else last_fire * 1.5 + 1 + ) + method = context.method or "ode" + boundaries = [0.0] + boundaries.extend(time for time, _sets in merged if time > 0) + boundaries.append(t_final) + boundaries = [ + value + for index, value in enumerate(boundaries) + if index == 0 or value > boundaries[index - 1] + ] + total_duration = t_final - boundaries[0] + + def steps_for(start: float, end: float) -> int: + scale = (end - start) / total_duration if total_duration > 0 else 1 + # JavaScript Math.round rounds nonnegative half ties upward; Python round ties to even. + return max(1, math.floor(context.base_steps * scale + 0.5)) + + lines = [ + f"# {len(merged)} time-triggered SBML event(s) translated to scheduled actions.", + "generate_network({overwrite=>1})", + ] + for time, sets in merged: + if time <= 0: + lines.extend( + _render_set(kind, target, value) for kind, target, value in sets + ) + + phase_start = 0.0 + first = True + for end in boundaries[1:]: + steps = steps_for(phase_start, end) + if first: + lines.append( + f'simulate({{method=>"{method}", t_start=>0, ' + f"t_end=>{_format_number(end)}, n_steps=>{steps}}})" + ) + first = False + else: + lines.append( + f'simulate({{continue=>1, method=>"{method}", ' + f"t_end=>{_format_number(end)}, n_steps=>{steps}}})" + ) + for time, sets in merged: + if time > 0 and abs(time - end) < 1e-12: + lines.extend( + _render_set(kind, target, value) for kind, target, value in sets + ) + phase_start = end + + if abs(phase_start - t_final) > 1e-12: + lines.append( + f'simulate({{continue=>1, method=>"{method}", ' + f"t_end=>{_format_number(t_final)}, " + f"n_steps=>{steps_for(phase_start, t_final)}}})" + ) + + return EventTranslationResult("\n".join(lines), len(scheduled), untranslated) + + +foldNumeric = fold_numeric +parseTimeThreshold = parse_time_threshold + + +__all__ = [ + "EventActionsResult", + "EventSet", + "EventTranslationContext", + "EventTranslationResult", + "foldNumeric", + "fold_numeric", + "parseTimeThreshold", + "parse_time_threshold", + "synthesize_event_actions", +] diff --git a/python/bionetgen/atomizer/modern/helpers.py b/python/bionetgen/atomizer/modern/helpers.py new file mode 100644 index 00000000..b678e64b --- /dev/null +++ b/python/bionetgen/atomizer/modern/helpers.py @@ -0,0 +1,555 @@ +"""Public utility surface ported from the Playground atomizer helpers.""" + +from __future__ import annotations + +import copy +import os +import random +import re +import sys +from dataclasses import dataclass +from datetime import datetime +from functools import wraps +from typing import Any, Callable, Dict, Generic, Iterable, List, Optional +from typing import Set, Tuple, TypeVar + +from .core import levenshtein, similarity +from .types import standardize_name + +T = TypeVar("T") +K = TypeVar("K") +V = TypeVar("V") +R = TypeVar("R") + + +def randInt(minimum: int, maximum: int) -> int: + """Return a random integer in the inclusive range from the reference API.""" + + return random.randint(minimum, maximum) + + +def deepCopy(value: T) -> T: + """Return a recursive copy, matching the helper's public contract.""" + + return copy.deepcopy(value) + + +class Counter(Dict[T, int], Generic[T]): + """Small Map-like occurrence counter used by the atomizer reference.""" + + def __init__(self, items: Optional[Iterable[T]] = None) -> None: + super().__init__() + if items is not None: + self.update(items) + + def update(self, items: Iterable[T]) -> None: # type: ignore[override] + for item in items: + self[item] = self.get(item, 0) + 1 + + def getCount(self, item: T) -> int: + return self.get(item, 0) + + def mostCommon(self, count: Optional[int] = None) -> List[Tuple[T, int]]: + values = sorted(self.items(), key=lambda item: item[1], reverse=True) + return values if count is None else values[:count] + + def total(self) -> int: + return sum(self.values()) + + def elements(self) -> List[T]: + result: List[T] = [] + for item, count in self.items(): + result.extend([item] * max(0, count)) + return result + + def subtract(self, other: "Counter[T] | Iterable[T]") -> None: + if isinstance(other, Counter): + for item, count in other.items(): + self[item] = self.get(item, 0) - count + return + for item in other: + self[item] = self.get(item, 0) - 1 + + # Python spellings are useful to callers while the camelCase names retain + # the public names exported by the TypeScript reference. + get_count = getCount + most_common = mostCommon + + +class DefaultDict(Dict[K, V], Generic[K, V]): + """Dictionary whose ``get`` creates a value for missing keys.""" + + def __init__(self, default_factory: Callable[[], V]) -> None: + super().__init__() + self.default_factory = default_factory + + def get(self, key: K, default: object = None) -> V: # type: ignore[override] + if key not in self: + value = self.default_factory() + self[key] = value + return value + return super().get(key) # type: ignore[return-value] + + +_GLOBAL_MEMO_CACHES: Dict[str, Dict[str, Any]] = {} + + +def pmemoize( + function: Callable[..., R], cache_key: Optional[str] = None +) -> Callable[..., R]: + """Memoize calls using the Playground's process-global named caches.""" + + name = cache_key or f"{function.__module__}.{function.__qualname__}" + cache = _GLOBAL_MEMO_CACHES.setdefault(name, {}) + + @wraps(function) + def memoized(*args: Any, **kwargs: Any) -> R: + key = repr((args, sorted(kwargs.items()))) + if key not in cache: + cache[key] = function(*args, **kwargs) + return cache[key] + + return memoized + + +class Memoize(Generic[R]): + def __init__(self, function: Callable[..., R]) -> None: + self.function = function + self.cache: Dict[str, R] = {} + + def call(self, *args: Any) -> R: + key = repr(args) + if key not in self.cache: + self.cache[key] = self.function(*args) + return self.cache[key] + + def clear(self) -> None: + self.cache.clear() + + +class MemoizeMapped(Generic[R]): + def __init__( + self, function: Callable[..., R], key_function: Callable[..., str] + ) -> None: + self.function = function + self.key_function = key_function + self.cache: Dict[str, R] = {} + + def call(self, *args: Any) -> R: + key = self.key_function(*args) + if key not in self.cache: + self.cache[key] = self.function(*args) + return self.cache[key] + + def clear(self) -> None: + self.cache.clear() + + +def sequenceMatcherRatio(first: List[T], second: List[T]) -> float: + if not first and not second: + return 1.0 + if not first or not second: + return 0.0 + matches = 0 + remaining = list(second) + for item in first: + if item in remaining: + matches += 1 + remaining.remove(item) + return (2 * matches) / (len(first) + len(second)) + + +def longestCommonSubstring(first: str, second: str) -> str: + if not first or not second: + return "" + previous = [0] * (len(second) + 1) + longest = 0 + end = 0 + for first_index, first_char in enumerate(first, 1): + current = [0] + for second_index, second_char in enumerate(second, 1): + value = previous[second_index - 1] + 1 if first_char == second_char else 0 + current.append(value) + if value > longest: + longest = value + end = first_index + previous = current + return first[end - longest : end] + + +def isValidBNGLName(name: str) -> bool: + return bool(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name)) + + +def factorial(value: float) -> int: + if value <= 1: + return 1 + result = 1 + current = 2 + while current <= value: + result *= current + current += 1 + return result + + +def comb(n: float, k: float) -> float: + if k < 0 or k > n: + return 0 + if k == 0 or k == n: + return 1 + return factorial(n) / (factorial(k) * factorial(n - k)) + + +def _split_arguments(value: str) -> List[str]: + arguments: List[str] = [] + start = 0 + depth = 0 + for index, character in enumerate(value): + if character == "(": + depth += 1 + elif character == ")": + depth -= 1 + elif character == "," and depth == 0: + arguments.append(value[start:index].strip()) + start = index + 1 + arguments.append(value[start:].strip()) + return arguments + + +def _replace_calls( + expression: str, name: str, render: Callable[[List[str]], str] +) -> str: + pattern = re.compile(rf"\b{re.escape(name)}\s*\(") + result = expression + cursor = 0 + while True: + match = pattern.search(result, cursor) + if match is None: + return result + opening = result.find("(", match.start(), match.end()) + depth = 0 + closing = -1 + for index in range(opening, len(result)): + character = result[index] + if character == "(": + depth += 1 + elif character == ")": + depth -= 1 + if depth == 0: + closing = index + break + if closing < 0: + return result + arguments = _split_arguments(result[opening + 1 : closing]) + original = result[match.start() : closing + 1] + replacement = render(arguments) + if replacement == "": + replacement = original + result = result[: match.start()] + replacement + result[closing + 1 :] + cursor = match.start() + len(replacement) + + +def convertMathFunction(math_string: str) -> str: + """Convert common MathML/libSBML function spellings from the reference.""" + + result = str(math_string) + result = _replace_calls( + result, + "pow", + lambda args: ( + f"(({args[0]})^({args[1]}))" + if len(args) >= 2 + else "pow(" + ", ".join(args) + ")" + ), + ) + result = _replace_calls( + result, + "sqrt", + lambda args: f"(({args[0]})^(1/2))" if args else "sqrt()", + ) + result = _replace_calls( + result, + "sqr", + lambda args: f"(({args[0]})^2)" if args else "sqr()", + ) + result = _replace_calls( + result, + "root", + lambda args: ( + f"(({args[1]})^(1/({args[0]})))" + if len(args) >= 2 + else "root(" + ", ".join(args) + ")" + ), + ) + result = _replace_calls( + result, + "exp", + lambda args: (f"(2.71828182845905^({args[0]}))" if args else "exp()"), + ) + + # Resolve log's two-argument base form before the one-argument natural log. + result = _replace_calls( + result, + "log", + lambda args: ( + f"(ln({args[1]})/ln({args[0]}))" + if len(args) >= 2 + else f"ln({args[0]})" if args else "log()" + ), + ) + result = _replace_calls( + result, + "log10", + lambda args: f"(ln({args[0]})/2.302585093)" if args else "log10()", + ) + + for name, operator in { + "gt": ">", + "lt": "<", + "geq": ">=", + "leq": "<=", + "eq": "==", + "neq": "!=", + "and": "&&", + "or": "||", + }.items(): + result = _replace_calls( + result, + name, + lambda args, operator=operator, name=name: ( + f"({args[0]} {operator} {args[1]})" + if len(args) >= 2 + else f"{name}(" + ", ".join(args) + ")" + ), + ) + result = _replace_calls( + result, + "not", + lambda args: ( + f"(!{args[0]})" if len(args) == 1 else "not(" + ", ".join(args) + ")" + ), + ) + result = _replace_calls( + result, + "ceil", + lambda args: ( + f"min(rint(({args[0]})+0.5),rint(({args[0]})+1))" if args else "ceil()" + ), + ) + result = _replace_calls( + result, + "floor", + lambda args: ( + f"min(rint(({args[0]})-0.5),rint(({args[0]})+0.5))" if args else "floor()" + ), + ) + + result = re.sub(r"\binf\b", "1e20", result, flags=re.IGNORECASE) + result = re.sub(r"\bpi\b", "3.14159265358979", result) + result = re.sub(r"\be\b(?!\s*\^)", "2.71828182845905", result) + result = re.sub(r"\btrue\b", "1", result, flags=re.IGNORECASE) + result = re.sub(r"\bfalse\b", "0", result, flags=re.IGNORECASE) + return result + + +def cleanParameterValue(value: str) -> str: + result = re.sub(r"\b(?:infinity|inf)\b", "1e20", str(value), flags=re.IGNORECASE) + result = re.sub(r"\bnan\b", "0", result, flags=re.IGNORECASE) + return re.sub( + r"(? None: + level = os.environ.get("ATOMIZER_LOG_LEVEL", "WARNING").upper() + self._level = level if level in _LOG_LEVELS else "WARNING" + self._quiet = os.environ.get("ATOMIZER_QUIET", "") == "1" + self._messages: List[LogMessage] = [] + + def setLevel(self, level: LogLevel) -> None: + self._level = level + + def setQuietMode(self, quiet: bool) -> None: + self._quiet = quiet + + def log( + self, + level: LogLevel, + code: str, + message: str, + context: Optional[str] = None, + ) -> None: + if _LOG_LEVELS[level] < _LOG_LEVELS[self._level]: + return + entry = LogMessage(level, code, message, context, datetime.now()) + self._messages.append(entry) + if self._quiet: + return + suffix = f" ({context})" if context else "" + stream = sys.stderr if level in {"WARNING", "ERROR", "CRITICAL"} else sys.stdout + print(f"[{level}] {code}: {message}{suffix}", file=stream) + + def debug(self, code: str, message: str, context: Optional[str] = None) -> None: + self.log("DEBUG", code, message, context) + + def info(self, code: str, message: str, context: Optional[str] = None) -> None: + self.log("INFO", code, message, context) + + def warning(self, code: str, message: str, context: Optional[str] = None) -> None: + self.log("WARNING", code, message, context) + + def error(self, code: str, message: str, context: Optional[str] = None) -> None: + self.log("ERROR", code, message, context) + + def critical(self, code: str, message: str, context: Optional[str] = None) -> None: + self.log("CRITICAL", code, message, context) + + def getMessages(self) -> List[LogMessage]: + return list(self._messages) + + def clear(self) -> None: + self._messages.clear() + + def getMessagesByLevel(self, level: LogLevel) -> List[LogMessage]: + return [message for message in self._messages if message.level == level] + + def hasErrors(self) -> bool: + return any(message.level in {"ERROR", "CRITICAL"} for message in self._messages) + + +logger = Logger() + + +def logMess(code_and_level: str, message: str) -> None: + match = re.match(r"^(DEBUG|INFO|WARNING|ERROR|CRITICAL):(.+)$", code_and_level) + if match: + logger.log(match.group(1), match.group(2), message) + return + parts = code_and_level.split(":") + if len(parts) >= 2 and parts[0] in _LOG_LEVELS: + logger.log(parts[0], ":".join(parts[1:]), message) + else: + logger.info(code_and_level, message) + + +def isNode() -> bool: + """The Python port is never running in the TypeScript Node runtime.""" + + return False + + +def compareLists(first: List[T], second: List[T]) -> bool: + if len(first) != len(second): + return False + left = Counter(map(str, first)) + right = Counter(map(str, second)) + return left == right + + +def setIntersection(first: Set[T], second: Set[T]) -> Set[T]: + return first.intersection(second) + + +def setDifference(first: Set[T], second: Set[T]) -> Set[T]: + return first.difference(second) + + +def setUnion(first: Set[T], second: Set[T]) -> Set[T]: + return first.union(second) + + +class TranslationException(Exception): + pass + + +class CycleError(Exception): + def __init__(self, memory: Any) -> None: + super().__init__("Cycle detected in dependency graph") + self.memory = memory + + +class BindingException(Exception): + def __init__(self, value: Any, combinations: Any) -> None: + super().__init__(str(value)) + self.value = value + self.combinations = combinations + + +# CamelCase names are the Playground exports. Snake-case aliases make the +# same utility surface natural for Python callers. +standardizeName = standardize_name +is_valid_bngl_name = isValidBNGLName +convert_math_function = convertMathFunction +clean_parameter_value = cleanParameterValue +compare_lists = compareLists +set_intersection = setIntersection +set_difference = setDifference +set_union = setUnion +rand_int = randInt +deep_copy = deepCopy +log_mess = logMess +sequence_matcher_ratio = sequenceMatcherRatio +longest_common_substring = longestCommonSubstring + + +__all__ = [ + "BindingException", + "Counter", + "CycleError", + "DefaultDict", + "LogLevel", + "LogMessage", + "Logger", + "Memoize", + "MemoizeMapped", + "TranslationException", + "cleanParameterValue", + "clean_parameter_value", + "comb", + "compareLists", + "compare_lists", + "convertMathFunction", + "convert_math_function", + "deepCopy", + "deep_copy", + "factorial", + "isNode", + "isValidBNGLName", + "is_valid_bngl_name", + "levenshtein", + "logMess", + "log_mess", + "logger", + "longestCommonSubstring", + "longest_common_substring", + "pmemoize", + "randInt", + "rand_int", + "sequenceMatcherRatio", + "sequence_matcher_ratio", + "setDifference", + "setIntersection", + "setUnion", + "set_difference", + "set_intersection", + "set_union", + "similarity", + "standardizeName", + "standardize_name", +] diff --git a/python/bionetgen/atomizer/modern/multi.py b/python/bionetgen/atomizer/modern/multi.py new file mode 100644 index 00000000..1d45dd65 --- /dev/null +++ b/python/bionetgen/atomizer/modern/multi.py @@ -0,0 +1,414 @@ +"""Conservative SBML Multi-package extraction for the modern atomizer. + +The Playground implementation reconstructs the canonical, single-level Multi +package idiom but deliberately does not flatten the deeper Simmune hierarchy. +This module mirrors that boundary: extracted structures are retained as +commented diagnostics and are not injected into the simulated BNGL network. +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from typing import Any, Dict, List, NamedTuple, Optional, Tuple, Union + +from .types import SBMLImportWarning + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _namespace(tag: str) -> str: + return tag[1:].split("}", 1)[0] if tag.startswith("{") else "" + + +def _attribute(element: Any, name: str, default: str = "") -> str: + for key, value in getattr(element, "attrib", {}).items(): + if key == name or _local_name(key) == name: + return str(value) + return default + + +def _children(element: Any, name: str, namespace: Optional[str] = None) -> List[Any]: + if element is None: + return [] + return [ + child + for child in list(element) + if _local_name(child.tag) == name + and (namespace is None or _namespace(child.tag) == namespace) + ] + + +def _first_child( + element: Any, name: str, namespace: Optional[str] = None +) -> Optional[Any]: + children = _children(element, name, namespace) + return children[0] if children else None + + +def _clean(value: str) -> str: + return re.sub(r"[^A-Za-z0-9_]", "_", value or "") + + +@dataclass +class _Instance: + id: str + type_id: str + name: str + + +@dataclass +class _SpeciesType: + id: str + name: str + features: List[Tuple[str, List[str]]] = field(default_factory=list) + instances: List[_Instance] = field(default_factory=list) + component_indexes: Dict[str, Tuple[str, str]] = field(default_factory=dict) + bonds: List[Tuple[str, str]] = field(default_factory=list) + + +class MultiComplexPattern(NamedTuple): + """Reference-shaped Multi complex record with legacy tuple behavior.""" + + type_id: str + pattern: str + + @property + def typeId(self) -> str: + return self.type_id + + +@dataclass +class MultiParseResult: + present: bool = False + deep: bool = False + bngl_molecule_types: List[str] = field(default_factory=list) + complex_patterns: List[MultiComplexPattern] = field(default_factory=list) + seed_patterns: List[Tuple[str, str]] = field(default_factory=list) + warnings: List[SBMLImportWarning] = field(default_factory=list) + + @property + def bnglMoleculeTypes(self) -> List[str]: + return self.bngl_molecule_types + + @bnglMoleculeTypes.setter + def bnglMoleculeTypes(self, value: List[str]) -> None: + self.bngl_molecule_types = value + + @property + def complexPatterns(self) -> List[MultiComplexPattern]: + return self.complex_patterns + + @complexPatterns.setter + def complexPatterns(self, value: List[MultiComplexPattern]) -> None: + self.complex_patterns = value + + @property + def seedPatterns(self) -> List[Tuple[str, str]]: + return self.seed_patterns + + @seedPatterns.setter + def seedPatterns(self, value: List[Tuple[str, str]]) -> None: + self.seed_patterns = value + + +def _warning(message: str, severity: str = "approximated") -> SBMLImportWarning: + return SBMLImportWarning( + category="package:multi", message=message, count=1, severity=severity + ) + + +def _as_root(document: Union[str, Any]) -> Any: + if isinstance(document, str): + if not document.strip(): + return None + return ET.fromstring(document) + return document + + +def _multi_namespace(root: Any) -> Optional[str]: + for element in root.iter(): + namespace = _namespace(element.tag) + if "/multi/" in namespace: + return namespace + return None + + +def _model_element(root: Any) -> Optional[Any]: + return next( + (element for element in root.iter() if _local_name(element.tag) == "model"), + None, + ) + + +def _sites_of(species_type: _SpeciesType, binding_sites: set) -> List[Tuple[set, str]]: + result = [] + for instance in species_type.instances: + if instance.type_id in binding_sites: + result.append( + ( + {instance.id, instance.type_id, instance.name}, + _clean(instance.name or instance.id), + ) + ) + return result + + +def _declaration(species_type: _SpeciesType, binding_sites: set) -> str: + features = [ + f"{_clean(name)}~{'~'.join(_clean(state) for state in states)}" + for name, states in species_type.features + ] + sites = [label for _keys, label in _sites_of(species_type, binding_sites)] + return f"{species_type.name}({','.join(features + sites)})" + + +def parse_multi_package(document: Union[str, Any]) -> MultiParseResult: + """Extract canonical Multi-package molecule/complex references.""" + + root = _as_root(document) + if root is None: + return MultiParseResult() + namespace = _multi_namespace(root) + if namespace is None: + return MultiParseResult() + + model = _model_element(root) + list_types = _first_child(model, "listOfSpeciesTypes", namespace) + if list_types is None: + return MultiParseResult( + present=True, + warnings=[ + _warning( + "SBML Multi package is present but no listOfSpeciesTypes was found.", + "info", + ) + ], + ) + + binding_sites = { + _attribute(item, "id") + for item in _children(list_types, "bindingSiteSpeciesType", namespace) + if _attribute(item, "id") + } + species_types: Dict[str, _SpeciesType] = {} + for item in _children(list_types, "speciesType", namespace): + type_id = _attribute(item, "id") + if not type_id: + continue + species_type = _SpeciesType( + id=type_id, + name=_clean(_attribute(item, "name") or type_id), + ) + feature_list = _first_child(item, "listOfSpeciesFeatureTypes", namespace) + for feature in _children(feature_list, "speciesFeatureType", namespace): + values: List[str] = [] + possible = _first_child( + feature, "listOfPossibleSpeciesFeatureValues", namespace + ) + for value in _children(possible, "possibleSpeciesFeatureValue", namespace): + label = _clean(_attribute(value, "name") or _attribute(value, "id")) + if label: + values.append(label) + feature_name = _clean( + _attribute(feature, "name") or _attribute(feature, "id") + ) + if feature_name: + species_type.features.append((feature_name, values)) + + instance_list = _first_child(item, "listOfSpeciesTypeInstances", namespace) + for instance in _children(instance_list, "speciesTypeInstance", namespace): + instance_id = _attribute(instance, "id") + instance_type = _attribute(instance, "speciesType") + if instance_id and instance_type: + species_type.instances.append( + _Instance( + id=instance_id, + type_id=instance_type, + name=_clean(_attribute(instance, "name") or instance_id), + ) + ) + + index_list = _first_child(item, "listOfSpeciesTypeComponentIndexes", namespace) + for component in _children(index_list, "speciesTypeComponentIndex", namespace): + component_id = _attribute(component, "id") + if component_id: + species_type.component_indexes[component_id] = ( + _attribute(component, "component"), + _attribute(component, "identifyingParent"), + ) + + bond_list = _first_child(item, "listOfInSpeciesTypeBonds", namespace) + for bond in _children(bond_list, "inSpeciesTypeBond", namespace): + site1 = _attribute(bond, "bindingSite1") + site2 = _attribute(bond, "bindingSite2") + if site1 and site2: + species_type.bonds.append((site1, site2)) + species_types[type_id] = species_type + + top_types = [] + for element in root.iter(): + for attribute, value in getattr(element, "attrib", {}).items(): + if _local_name(attribute) != "speciesType": + continue + if _namespace(attribute) != namespace: + continue + type_id = str(value) + if type_id in species_types and type_id not in top_types: + top_types.append(type_id) + + # Keep compatibility with hand-authored fixtures that use an unqualified + # speciesType attribute on a core species element. + species_list = _first_child(model, "listOfSpecies") + for species in _children(species_list, "species"): + type_id = _attribute(species, "speciesType") + if type_id in species_types and type_id not in top_types: + top_types.append(type_id) + + if not top_types: + return MultiParseResult( + present=True, + warnings=[ + _warning( + "SBML Multi package has species types but no referenced top-level species type.", + "info", + ) + ], + ) + + def is_container(species_type: _SpeciesType) -> bool: + return any( + instance.type_id in species_types for instance in species_type.instances + ) + + deep = any( + any( + instance.type_id in species_types + and is_container(species_types[instance.type_id]) + for instance in species_types[type_id].instances + ) + for type_id in top_types + ) + if deep: + names = [] + for species_type in species_types.values(): + if species_type.name not in names and not re.match( + r"^(?:mcp|bst|cps|mol)[_-]?\d", species_type.name, re.IGNORECASE + ): + names.append(species_type.name) + return MultiParseResult( + present=True, + deep=True, + warnings=[ + _warning( + "SBML Multi package uses a multi-layer hierarchy; molecule " + "boundaries cannot be inferred safely, so complexes were not " + f"reconstructed. Molecule names: {', '.join(names[:20])}." + ) + ], + ) + + molecule_type_ids = [] + for type_id in top_types: + top = species_types[type_id] + subtypes = [ + instance.type_id + for instance in top.instances + if instance.type_id in species_types + ] + candidates = subtypes or [type_id] + for candidate in candidates: + if candidate not in molecule_type_ids: + molecule_type_ids.append(candidate) + molecule_types = [ + _declaration(species_types[type_id], binding_sites) + for type_id in molecule_type_ids + ] + + complex_patterns: List[MultiComplexPattern] = [] + unresolved = 0 + for type_id in top_types: + top = species_types[type_id] + subtypes = [ + instance for instance in top.instances if instance.type_id in species_types + ] + if not subtypes: + continue + + def resolve(reference: str) -> Optional[Tuple[str, str]]: + component, parent = top.component_indexes.get(reference, (reference, "")) + instance_id = "" + if parent and any(item.id == parent for item in subtypes): + instance_id = parent + elif any(item.id == component for item in subtypes): + instance_id = component + if not instance_id: + return None + instance = next(item for item in subtypes if item.id == instance_id) + sites = _sites_of(species_types[instance.type_id], binding_sites) + site = next((item for item in sites if component in item[0]), None) + if site is None and len(sites) == 1: + site = sites[0] + return (instance_id, site[1]) if site is not None else None + + bond_map: Dict[str, Dict[str, int]] = {} + ok = True + bond_number = 0 + for site1, site2 in top.bonds: + endpoint1 = resolve(site1) + endpoint2 = resolve(site2) + if endpoint1 is None or endpoint2 is None: + ok = False + break + bond_number += 1 + bond_map.setdefault(endpoint1[0], {})[endpoint1[1]] = bond_number + bond_map.setdefault(endpoint2[0], {})[endpoint2[1]] = bond_number + if not ok: + unresolved += 1 + continue + + molecules = [] + for instance in subtypes: + sites = _sites_of(species_types[instance.type_id], binding_sites) + labels = [] + for _keys, label in sites: + bond = bond_map.get(instance.id, {}).get(label) + labels.append(f"{label}!{bond}" if bond is not None else label) + molecules.append( + f"{species_types[instance.type_id].name}({','.join(labels)})" + ) + complex_patterns.append(MultiComplexPattern(type_id, ".".join(molecules))) + + warnings = [] + if unresolved: + warnings.append( + _warning( + f"{unresolved} Multi complex type(s) had bonds that could not be " + "resolved; molecule types were retained." + ) + ) + if molecule_types: + suffix = ( + f" Reconstructed {len(complex_patterns)} bonded complex pattern(s)." + if complex_patterns + else "" + ) + warnings.append( + _warning( + f"SBML Multi package: extracted {len(molecule_types)} molecule " + f"type(s) with binding sites and states.{suffix}" + ) + ) + return MultiParseResult( + present=True, + bngl_molecule_types=molecule_types, + complex_patterns=complex_patterns, + seed_patterns=[], + warnings=warnings, + ) + + +__all__ = ["MultiParseResult", "parse_multi_package"] diff --git a/python/bionetgen/atomizer/modern/parser.py b/python/bionetgen/atomizer/modern/parser.py new file mode 100644 index 00000000..d3e072d4 --- /dev/null +++ b/python/bionetgen/atomizer/modern/parser.py @@ -0,0 +1,1488 @@ +"""Pure-XML SBML parser for the Playground-derived atomizer. + +The parser deliberately accepts SBML as a string, matching the +Playground implementation while keeping the modern path independent of the +optional and platform-sensitive libSBML SWIG bindings. +""" + +from __future__ import annotations + +import math +import re +import xml.etree.ElementTree as ET +from collections import OrderedDict +from typing import Any, Dict, Iterable, List, Optional + +from .types import ( + AnnotationInfo, + SBMLEvent, + SBMLCompartment, + SBMLFunctionDefinition, + SBMLInitialAssignment, + SBMLKineticLaw, + SBMLModel, + coerce_import_warning, + SBMLModifierSpeciesReference, + SBMLParameter, + SBMLReaction, + SBMLRule, + SBMLSpecies, + SBMLSpeciesReference, + standardize_name, +) +from .multi import parse_multi_package +from .units import apply_unit_scaling +from .helpers import logger + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _children(element: Any, name: str) -> Iterable[Any]: + return (child for child in list(element) if _local_name(child.tag) == name) + + +def _first_child(element: Any, name: str) -> Optional[Any]: + return next(iter(_children(element, name)), None) + + +def _attribute(element: Any, name: str, default: Any = None) -> Any: + for key, value in getattr(element, "attrib", {}).items(): + if key == name or key.rsplit("}", 1)[-1] == name: + return value + return default + + +def _float(value: Any, default: float = 0.0) -> float: + try: + result = float(value) + return result if math.isfinite(result) else default + except (TypeError, ValueError): + return default + + +def _bool(value: Any, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes"} + + +def _formula_from_math(math_node: Any, libsbml: Any) -> str: + if math_node is None: + return "" + try: + formula = libsbml.formulaToString(math_node) + except Exception: + formula = "" + if formula: + return str(formula).strip() + return "" + + +def _mathml_text(element: Optional[Any]) -> str: + if element is None: + return "" + return "".join(element.itertext()).strip() + + +def _mathml_to_formula(element: Optional[Any]) -> str: + """Translate the MathML subset used by SBML into stable infix/function text.""" + + if element is None: + return "" + tag = _local_name(element.tag) + children = [child for child in list(element) if _local_name(child.tag) != "#text"] + + if tag in {"math", "semantics", "annotation-xml", "condition"}: + return next( + ( + expression + for child in children + if (expression := _mathml_to_formula(child).strip()) + ), + "", + ) + if tag in {"ci", "csymbol"}: + definition_url = str(_attribute(element, "definitionURL", "") or "").lower() + if "symbols/time" in definition_url: + return "time" + if "symbols/avogadro" in definition_url: + return "__Avogadro__" + return _mathml_text(element) + if tag == "cn": + chunks: List[str] = [] + if element.text and element.text.strip(): + chunks.append(element.text.strip()) + for child in children: + if _local_name(child.tag) == "sep": + if child.tail and child.tail.strip(): + chunks.append(child.tail.strip()) + elif child.tail and child.tail.strip(): + chunks.append(child.tail.strip()) + if len(chunks) >= 2 and any( + _local_name(child.tag) == "sep" for child in children + ): + number_type = str(_attribute(element, "type", "") or "").lower() + if number_type in {"e-notation", "enotation"}: + return f"({chunks[0]} * 10^({chunks[1]}))" + return f"({chunks[0]} / {chunks[1]})" + return _mathml_text(element) + if tag == "true": + return "1" + if tag == "false": + return "0" + if tag == "pi": + return "3.141592653589793" + if tag == "exponentiale": + return "2.718281828459045" + if tag == "infinity": + return "1e308" + if tag == "notanumber": + return "0" + if tag in {"lambda", "bvar", "piece", "otherwise"}: + return next( + ( + expression + for child in children + if (expression := _mathml_to_formula(child).strip()) + ), + _mathml_text(element) if tag == "bvar" else "", + ) + if tag == "piecewise": + branches: List[tuple[str, str]] = [] + fallback = "0" + for part in children: + part_tag = _local_name(part.tag) + part_children = [ + child for child in list(part) if _local_name(child.tag) != "#text" + ] + if part_tag == "piece" and len(part_children) >= 2: + value = _mathml_to_formula(part_children[0]) + condition = _mathml_to_formula(part_children[1]) + branches.append((value, condition)) + elif part_tag == "otherwise" and part_children: + fallback = _mathml_to_formula(part_children[0]) + result = fallback + for value, condition in reversed(branches): + result = f"if({condition}, {value}, {result})" + return result + if tag == "apply": + if not children: + return "" + operator_node = children[0] + operator = _local_name(operator_node.tag) + if operator in {"ci", "csymbol"}: + function_name = _mathml_to_formula(operator_node) + args = [_mathml_to_formula(child) for child in children[1:]] + return ( + f"{function_name}({', '.join(args)})" + if function_name + else ", ".join(args) + ) + + degree = next( + (child for child in children[1:] if _local_name(child.tag) == "degree"), + None, + ) + logbase = next( + (child for child in children[1:] if _local_name(child.tag) == "logbase"), + None, + ) + args = [ + _mathml_to_formula(child) + for child in children[1:] + if _local_name(child.tag) not in {"degree", "logbase"} + ] + if operator in {"plus", "times", "minus", "divide", "power"}: + symbol = { + "plus": "+", + "times": "*", + "minus": "-", + "divide": "/", + "power": "^", + }[operator] + if operator == "minus" and len(args) == 1: + return f"-({args[0]})" + return f" {symbol} ".join(args) + if operator == "root": + if degree is not None: + return f"root({_mathml_to_formula(degree)}, {args[0] if args else ''})" + return f"sqrt({args[0] if args else ''})" + if operator == "log": + if logbase is not None: + return f"log({_mathml_to_formula(logbase)}, {args[0] if args else ''})" + return f"log10({args[0] if args else ''})" + if operator == "quotient": + return f"floor(({args[0]}) / ({args[1]}))" if len(args) >= 2 else "" + if operator == "rem": + return ( + f"(({args[0]}) - ({args[1]}) * floor(({args[0]}) / ({args[1]})))" + if len(args) >= 2 + else "" + ) + direct = { + "ceiling": "ceil", + "arcsin": "asin", + "arccos": "acos", + "arctan": "atan", + } + return f"{direct.get(operator, operator)}({', '.join(args)})" + return _mathml_to_formula(children[0]) if children else _mathml_text(element) + + +class SBMLParser: + """Parse SBML text into the atomizer's stable intermediate model.""" + + def parse(self, sbml_string: str) -> SBMLModel: + try: + root = ET.fromstring(sbml_string) + except ET.ParseError as exc: + raise ValueError(f"Invalid SBML XML: {exc}") from exc + model_element = next( + (element for element in root.iter() if _local_name(element.tag) == "model"), + None, + ) + if model_element is None: + raise ValueError("SBML document has no model") + declared_packages = { + match.group(1).lower() + for match in re.finditer( + r"xmlns(?::[A-Za-z0-9_]+)?\s*=\s*" + r"[\"']http://www\.sbml\.org/sbml/level3/version\d+/" + r"([a-z]+)/version\d+[\"']", + sbml_string, + re.IGNORECASE, + ) + } + return self._parse_xml_model(root, model_element, declared_packages) + + @staticmethod + def _xml_math(parent: Optional[Any]) -> str: + if parent is None: + return "" + math_element = ( + parent + if _local_name(getattr(parent, "tag", "")) == "math" + else _first_child(parent, "math") + ) + if math_element is None: + formula = _attribute(parent, "formula", "") + return str(formula or "").strip() + return _mathml_to_formula(math_element) + + @staticmethod + def _mathml_import_warnings(root: Any) -> List[Dict[str, Any]]: + """Collect the source parser's diagnostics for lossy MathML constructs.""" + + messages: "OrderedDict[str, int]" = OrderedDict() + + def add(message: str) -> None: + messages[message] = messages.get(message, 0) + 1 + + for math_element in root.iter(): + if _local_name(math_element.tag).lower() != "math": + continue + for element in math_element.iter(): + tag = _local_name(element.tag).lower() + if tag == "infinity": + add( + " constant encountered in math; emitted as a large finite value." + ) + elif tag == "notanumber": + add( + " constant encountered in math; cannot be represented." + ) + elif tag == "factorial": + add( + " used in math; emitted as factorial(x), which the engine may not support." + ) + elif tag in {"gcd", "lcm"}: + add( + f"<{tag}> used in math; the engine does not provide it. Emitted as {tag}(...)." + ) + elif tag == "cn" and any( + _local_name(child.tag).lower() == "sep" for child in list(element) + ): + number_type = str(_attribute(element, "type", "") or "").lower() + if number_type != "rational": + add( + " with and unspecified type treated as rational." + ) + + return [ + { + "category": "mathml", + "message": message, + "count": count, + "severity": "approximated", + } + for message, count in messages.items() + ] + + @staticmethod + def _xml_items(model: Any, container: str, item: str) -> List[Any]: + parent = _first_child(model, container) + return list(_children(parent, item)) if parent is not None else [] + + @staticmethod + def _register_alias( + aliases: Dict[str, str], alias_raw: Any, canonical_id: str + ) -> None: + """Register the source parser's direct, collapsed, and BNGL aliases.""" + + direct = str(alias_raw or "").strip() + if not direct: + return + candidates = (direct, re.sub(r"\s+", " ", direct), standardize_name(direct)) + seen = set() + for alias in candidates: + key = str(alias or "").strip() + if not key or key in seen: + continue + seen.add(key) + existing = aliases.get(key) + if existing is None or existing == canonical_id: + aliases[key] = canonical_id + + @staticmethod + def _normalize_formula_identifiers( + formula: Any, *alias_maps: Optional[Dict[str, str]] + ) -> str: + """Rewrite reference aliases without touching longer identifiers.""" + + normalized = str(formula or "") + if not normalized: + return normalized + ordered_aliases = [] + for aliases in alias_maps: + if aliases is None: + continue + ordered_aliases.extend( + (alias, canonical) + for alias, canonical in aliases.items() + if alias and canonical and alias != canonical + ) + ordered_aliases.sort(key=lambda item: len(item[0]), reverse=True) + for alias, canonical in ordered_aliases: + pattern = re.compile( + rf"(^|[^A-Za-z0-9_]){re.escape(alias)}(?=$|[^A-Za-z0-9_])" + ) + normalized = pattern.sub( + lambda match: f"{match.group(1)}{canonical}", normalized + ) + return normalized + + @staticmethod + def _parse_xml_model( + root: Any, model: Any, declared_packages: Optional[Iterable[str]] = None + ) -> SBMLModel: + compartments = SBMLParser._parse_xml_compartments(model) + species = SBMLParser._parse_xml_species(model) + parameter_warnings: List[Dict[str, Any]] = [] + parameter_aliases: Dict[str, str] = {} + parameters = SBMLParser._parse_xml_parameters( + model, parameter_warnings, parameter_aliases + ) + reactions = SBMLParser._parse_xml_reactions(model, parameter_aliases) + rules = SBMLParser._parse_xml_rules(model, parameter_aliases) + functions = SBMLParser._parse_xml_functions(model, parameter_aliases) + events = SBMLParser._parse_xml_events(model, parameter_aliases) + initial_assignments = SBMLParser._parse_xml_initial_assignments( + model, parameter_aliases + ) + species_by_compartment: Dict[str, List[str]] = OrderedDict() + for species_id, item in species.items(): + species_by_compartment.setdefault(item.compartment, []).append(species_id) + level = _attribute(root, "level") + try: + level_value = int(level) if level is not None else None + except (TypeError, ValueError): + level_value = None + model_id = str(_attribute(model, "id", "model") or "model") + result = SBMLModel( + id=model_id, + name=str(_attribute(model, "name", model_id) or model_id), + compartments=compartments, + species=species, + parameters=parameters, + reactions=reactions, + rules=rules, + function_definitions=functions, + events=events, + initial_assignments=initial_assignments, + species_by_compartment=species_by_compartment, + unit_definitions=SBMLParser._parse_xml_units(model), + level=level_value, + version=( + int(_attribute(root, "version")) + if str(_attribute(root, "version", "")).isdigit() + else None + ), + substance_units=str(_attribute(model, "substanceUnits", "") or ""), + time_units=str(_attribute(model, "timeUnits", "") or ""), + volume_units=str(_attribute(model, "volumeUnits", "") or ""), + area_units=str(_attribute(model, "areaUnits", "") or ""), + length_units=str(_attribute(model, "lengthUnits", "") or ""), + extent_units=str(_attribute(model, "extentUnits", "") or ""), + conversion_factor=( + str(_attribute(model, "conversionFactor")) + if _attribute(model, "conversionFactor") is not None + else None + ), + constraint_count=len( + SBMLParser._xml_items(model, "listOfConstraints", "constraint") + ), + ) + result.import_warnings.extend(SBMLParser._mathml_import_warnings(root)) + result.import_warnings.extend(apply_unit_scaling(result)) + result.import_warnings.extend(parameter_warnings) + for reaction_id, reaction in result.reactions.items(): + for reference in [*reaction.reactants, *reaction.products]: + value = reference.stoichiometry + if value == 0: + continue + if reference.variable_stoichiometry: + result.import_warnings.append( + { + "category": "stoichiometry", + "message": ( + f'Reaction "{reaction_id}" has variable ' + f'stoichiometry for species "{reference.species}"; ' + f"BNGL will use the parsed fixed value {value:g}." + ), + "count": 1, + "severity": "approximated", + } + ) + elif ( + not math.isfinite(value) + or value < 0 + or abs(value - round(value)) > 1e-9 + ): + result.import_warnings.append( + { + "category": "stoichiometry", + "message": ( + f'Reaction "{reaction_id}" has unsupported ' + f"stoichiometry {value:g} for species " + f'"{reference.species}"; the reaction will be omitted.' + ), + "count": 1, + "severity": "dropped", + } + ) + if reaction.fast: + result.import_warnings.append( + { + "category": "fastReaction", + "message": ( + f'Reaction "{reaction_id}" is marked fast ' + '(fast="true"); BNGL/BNG has no fast-equilibrium ' + "solve, so it is treated as an ordinary reaction." + ), + "count": 1, + "severity": "approximated", + } + ) + if reaction.conversion_factor: + result.import_warnings.append( + { + "category": "conversionFactor", + "message": ( + f'Reaction "{reaction_id}" declares ' + f'conversionFactor="{reaction.conversion_factor}"; ' + "captured but not applied to the rate law." + ), + "count": 1, + "severity": "approximated", + } + ) + if result.events: + result.import_warnings.append( + { + "category": "event", + "message": ( + f"{len(result.events)} SBML event(s) parsed; discrete state " + "changes are not executed by the simulation engine and are " + "emitted as an annotated block for review." + ), + "count": len(result.events), + "severity": "dropped", + } + ) + algebraic_count = sum(rule.type == "algebraic" for rule in result.rules) + if algebraic_count: + result.import_warnings.append( + { + "category": "algebraicRule", + "message": ( + f"{algebraic_count} algebraic rule(s) present; these are " + "implicit DAE constraints with no BNGL equivalent and are " + "not applied." + ), + "count": algebraic_count, + "severity": "dropped", + } + ) + dynamic_packages = { + "comp": "hierarchical model composition (submodels/externalModelDefinitions are not flattened)", + "multi": "multistate/multicomponent species beyond the conservative reference extraction", + "fbc": "flux-balance constraints and objectives", + "qual": "qualitative (logical) model transitions", + "spatial": "spatial geometry and diffusion", + "arrays": "array-expanded objects", + "distrib": "distributions and uncertainty", + "dyn": "dynamic (agent) behaviour", + } + benign_packages = { + "layout": "diagram layout", + "render": "diagram rendering", + "groups": "element grouping", + } + package_counts: Dict[str, int] = { + str(package).lower(): 0 for package in (declared_packages or []) + } + package_uri = re.compile( + r"^http://www\.sbml\.org/sbml/level3/version\d+/" r"([a-z]+)/version\d+$", + re.IGNORECASE, + ) + for element in root.iter(): + tag = str(getattr(element, "tag", "")) + if not tag.startswith("{") or "}" not in tag: + continue + uri = tag[1:].split("}", 1)[0] + match = package_uri.match(uri) + if match: + package_counts[match.group(1).lower()] = ( + package_counts.get(match.group(1).lower(), 0) + 1 + ) + package_reasons = { + "fbc": " This is a constraint-based flux-balance model, not a kinetic time-course model.", + "qual": " This is a discrete logical model, not a continuous-time kinetic network.", + } + for package, description in dynamic_packages.items(): + if package not in package_counts: + continue + count = package_counts[package] + result.import_warnings.append( + { + "category": f"package:{package}", + "message": ( + f'SBML "{package}" package detected ({count} element(s)): ' + f"{description}. This package is not imported; affected " + f"structure is missing from the atomized model." + f"{package_reasons.get(package, '')}" + ), + "count": count or 1, + "severity": "dropped", + } + ) + for package, description in benign_packages.items(): + if package not in package_counts: + continue + result.import_warnings.append( + { + "category": f"package:{package}", + "message": ( + f'SBML "{package}" package detected ({description}); ' + "not imported. This does not affect the mathematical model." + ), + "count": package_counts[package] or 1, + "severity": "info", + } + ) + if package_counts.get("qual", 0) > 0 and not result.reactions: + raise ValueError( + 'Unsupported model class: SBML "qual" qualitative/logical ' + "model cannot be represented as a BNGL rule-based network." + ) + multi = parse_multi_package(root) + result.import_warnings.extend(multi.warnings) + result.multi_molecule_types = list(multi.bngl_molecule_types) + result.multi_complex_patterns = [ + pattern for _type_id, pattern in multi.complex_patterns + ] + result.multi_seed_patterns = [ + f"{species}: {pattern}" for species, pattern in multi.seed_patterns + ] + if result.constraint_count: + result.import_warnings.append( + { + "category": "constraint", + "message": ( + f"{result.constraint_count} SBML constraint element(s) present; " + "constraints are not enforced during simulation." + ), + "count": result.constraint_count, + "severity": "info", + } + ) + result.import_warnings = [ + coerce_import_warning(warning) for warning in result.import_warnings + ] + logger.info( + "SBM004", + f"Parsed SBML model: {len(result.species)} species, " + f"{len(result.reactions)} reactions", + ) + for warning in result.import_warnings: + code = { + "duplicateParameter": "SBM010", + }.get( + warning.get("category"), + { + "dropped": "SBM020", + "approximated": "SBM021", + }.get(warning.get("severity"), "SBM022"), + ) + count = warning.get("count", 1) + suffix = f" (x{count})" if count > 1 else "" + logger.warning( + code, + f"[{warning.get('category', 'unknown')}] " + f"{warning.get('message', '')}{suffix}", + ) + return result + + @staticmethod + def _parse_xml_compartments(model: Any) -> Dict[str, SBMLCompartment]: + result: Dict[str, SBMLCompartment] = OrderedDict() + for item in SBMLParser._xml_items(model, "listOfCompartments", "compartment"): + item_id = str(_attribute(item, "id", "") or "") + if not item_id: + continue + result[item_id] = SBMLCompartment( + id=item_id, + name=str(_attribute(item, "name", item_id) or item_id), + spatial_dimensions=_float(_attribute(item, "spatialDimensions"), 3), + size=_float(_attribute(item, "size", _attribute(item, "volume")), 1), + units=str(_attribute(item, "units", "") or ""), + constant=_bool(_attribute(item, "constant"), True), + outside=(str(_attribute(item, "outside", "") or "") or None), + compartment_type=_attribute(item, "compartmentType"), + size_set=( + _attribute(item, "size") is not None + or _attribute(item, "volume") is not None + ), + ) + return result + + @staticmethod + def _parse_xml_annotations(item: Any) -> List[AnnotationInfo]: + result: List[AnnotationInfo] = [] + for annotation in _children(item, "annotation"): + resources: List[str] = [] + qualifier_type = -1 + biological = None + model_qualifier = None + for descendant in annotation.iter(): + resource = _attribute(descendant, "resource") + if resource is not None and _local_name(descendant.tag) == "li": + resources.append(str(resource)) + namespace = str(descendant.tag) + if "biology-qualifiers" in namespace: + qualifier_type = 1 + biological = 0 if _local_name(descendant.tag) == "is" else None + elif "model-qualifiers" in namespace: + qualifier_type = 2 + model_qualifier = 0 if _local_name(descendant.tag) == "is" else None + if resources: + result.append( + AnnotationInfo( + qualifier_type=qualifier_type, + biological_qualifier=biological, + model_qualifier=model_qualifier, + resources=resources, + ) + ) + return result + + @staticmethod + def _parse_xml_species(model: Any) -> Dict[str, SBMLSpecies]: + result: Dict[str, SBMLSpecies] = OrderedDict() + for item in SBMLParser._xml_items(model, "listOfSpecies", "species"): + item_id = str(_attribute(item, "id", "") or "") + if not item_id: + continue + result[item_id] = SBMLSpecies( + id=item_id, + name=str(_attribute(item, "name", item_id) or item_id), + compartment=str(_attribute(item, "compartment", "") or ""), + initial_concentration=_float( + _attribute(item, "initialConcentration"), 0 + ), + initial_amount=_float(_attribute(item, "initialAmount"), 0), + substance_units=str(_attribute(item, "substanceUnits", "") or ""), + has_only_substance_units=_bool( + _attribute(item, "hasOnlySubstanceUnits"), False + ), + boundary_condition=_bool(_attribute(item, "boundaryCondition"), False), + constant=_bool(_attribute(item, "constant"), False), + annotations=SBMLParser._parse_xml_annotations(item), + initial_amount_set=_attribute(item, "initialAmount") is not None, + initial_concentration_set=( + _attribute(item, "initialConcentration") is not None + ), + sbo_term=_attribute(item, "sboTerm"), + conversion_factor=_attribute(item, "conversionFactor"), + charge=( + _float(_attribute(item, "charge")) + if _attribute(item, "charge") is not None + else None + ), + species_type=_attribute(item, "speciesType"), + ) + return result + + @staticmethod + def _parse_xml_parameters( + model: Any, + warnings: Optional[List[Dict[str, Any]]] = None, + aliases: Optional[Dict[str, str]] = None, + ) -> Dict[str, SBMLParameter]: + result: Dict[str, SBMLParameter] = OrderedDict() + for item in SBMLParser._xml_items(model, "listOfParameters", "parameter"): + raw_id = str(_attribute(item, "id", "") or "") + if not raw_id: + continue + item_id = standardize_name(raw_id) + parameter = SBMLParameter( + id=item_id, + name=str(_attribute(item, "name", item_id) or item_id), + value=_float(_attribute(item, "value"), 0), + units=str(_attribute(item, "units", "") or ""), + constant=_bool(_attribute(item, "constant"), True), + scope="global", + ) + existing = result.get(item_id) + if existing is not None: + values_match = ( + math.isfinite(existing.value) + and math.isfinite(parameter.value) + and abs(existing.value - parameter.value) <= 1e-12 + ) + if values_match: + # Match the reference parser: duplicate declarations with + # the same value are one parameter, not a silent overwrite. + if aliases is not None: + SBMLParser._register_alias(aliases, parameter.name, existing.id) + continue + suffix = 2 + remapped_id = f"{item_id}_{suffix}" + while remapped_id in result: + suffix += 1 + remapped_id = f"{item_id}_{suffix}" + parameter.id = remapped_id + if warnings is not None: + warnings.append( + { + "category": "duplicateParameter", + "message": ( + f'Duplicate parameter id "{item_id}" remapped ' + f'to "{remapped_id}"' + ), + "count": 1, + "severity": "approximated", + } + ) + result[parameter.id] = parameter + if aliases is not None: + SBMLParser._register_alias(aliases, raw_id, parameter.id) + SBMLParser._register_alias(aliases, parameter.id, parameter.id) + SBMLParser._register_alias(aliases, parameter.name, parameter.id) + return result + + @staticmethod + def _parse_xml_reference(item: Any) -> SBMLSpeciesReference: + species = str(_attribute(item, "species", "") or "") + stoichiometry_set = _attribute(item, "stoichiometry") is not None + stoichiometry = ( + _float(_attribute(item, "stoichiometry"), 1) if stoichiometry_set else 1 + ) + denominator = _float(_attribute(item, "denominator"), 0) + if denominator not in {0, 1}: + stoichiometry /= denominator + constant = _bool(_attribute(item, "constant"), True) + stoichiometry_math_set = _first_child(item, "stoichiometryMath") is not None + return SBMLSpeciesReference( + species=species, + stoichiometry=stoichiometry, + constant=constant, + id=(str(_attribute(item, "id")) if _attribute(item, "id") else None), + stoichiometry_set=stoichiometry_set, + variable_stoichiometry=not constant or stoichiometry_math_set, + ) + + @staticmethod + def _parse_xml_kinetic_law( + item: Any, parameter_aliases: Optional[Dict[str, str]] = None + ) -> Optional[SBMLKineticLaw]: + if item is None: + return None + math = SBMLParser._xml_math(item) + local_parameters: List[SBMLParameter] = [] + local_parent = _first_child(item, "listOfLocalParameters") + if local_parent is None: + local_parent = _first_child(item, "listOfParameters") + if local_parent is not None: + local_aliases: Dict[str, str] = {} + for local_index, local in enumerate( + _children(local_parent, "localParameter") + ): + raw_local_id = str(_attribute(local, "id", "") or "") + if not raw_local_id: + continue + local_id = standardize_name(raw_local_id) + if local_id in local_aliases: + local_id = f"{local_id}_{local_index + 1}" + parameter = SBMLParameter( + id=local_id, + name=str(_attribute(local, "name", local_id) or local_id), + value=_float(_attribute(local, "value"), 0), + units=str(_attribute(local, "units", "") or ""), + scope="local", + ) + local_parameters.append(parameter) + SBMLParser._register_alias(local_aliases, raw_local_id, parameter.id) + SBMLParser._register_alias(local_aliases, parameter.id, parameter.id) + SBMLParser._register_alias(local_aliases, parameter.name, parameter.id) + else: + local_aliases = {} + return SBMLKineticLaw( + math=SBMLParser._normalize_formula_identifiers( + math, local_aliases, parameter_aliases + ), + math_ml=( + ET.tostring(_first_child(item, "math"), encoding="unicode") + if _first_child(item, "math") is not None + else "" + ), + local_parameters=local_parameters, + ) + + @staticmethod + def _parse_xml_reactions( + model: Any, parameter_aliases: Optional[Dict[str, str]] = None + ) -> Dict[str, SBMLReaction]: + result: Dict[str, SBMLReaction] = OrderedDict() + for item in SBMLParser._xml_items(model, "listOfReactions", "reaction"): + item_id = str(_attribute(item, "id", "") or "") + if not item_id: + continue + reactant_parent = _first_child(item, "listOfReactants") + product_parent = _first_child(item, "listOfProducts") + modifier_parent = _first_child(item, "listOfModifiers") + result[item_id] = SBMLReaction( + id=item_id, + name=str(_attribute(item, "name", item_id) or item_id), + reversible=_bool(_attribute(item, "reversible"), False), + fast=_bool(_attribute(item, "fast"), False), + reactants=( + [ + SBMLParser._parse_xml_reference(reference) + for reference in _children(reactant_parent, "speciesReference") + ] + if reactant_parent is not None + else [] + ), + products=( + [ + SBMLParser._parse_xml_reference(reference) + for reference in _children(product_parent, "speciesReference") + ] + if product_parent is not None + else [] + ), + modifiers=( + [ + SBMLModifierSpeciesReference( + str(_attribute(reference, "species", "") or "") + ) + for reference in _children( + modifier_parent, "modifierSpeciesReference" + ) + ] + if modifier_parent is not None + else [] + ), + kinetic_law=SBMLParser._parse_xml_kinetic_law( + _first_child(item, "kineticLaw"), parameter_aliases + ), + compartment=( + str(_attribute(item, "compartment")) + if _attribute(item, "compartment") + else None + ), + conversion_factor=_attribute(item, "conversionFactor"), + ) + return result + + @staticmethod + def _parse_xml_rules( + model: Any, parameter_aliases: Optional[Dict[str, str]] = None + ) -> List[SBMLRule]: + result: List[SBMLRule] = [] + parent = _first_child(model, "listOfRules") + if parent is None: + return result + for item in list(parent): + kind = { + "assignmentRule": "assignment", + "rateRule": "rate", + "algebraicRule": "algebraic", + }.get(_local_name(item.tag)) + if kind is None: + continue + result.append( + SBMLRule( + type=kind, + variable=( + str(_attribute(item, "variable")) + if _attribute(item, "variable") is not None + else None + ), + math=SBMLParser._normalize_formula_identifiers( + SBMLParser._xml_math(item), parameter_aliases + ), + ) + ) + return result + + @staticmethod + def _parse_xml_functions( + model: Any, parameter_aliases: Optional[Dict[str, str]] = None + ) -> Dict[str, SBMLFunctionDefinition]: + result: Dict[str, SBMLFunctionDefinition] = OrderedDict() + for item in SBMLParser._xml_items( + model, "listOfFunctionDefinitions", "functionDefinition" + ): + item_id = str(_attribute(item, "id", "") or "") + if not item_id: + continue + math_element = _first_child(item, "math") + lambda_element = ( + _first_child(math_element, "lambda") + if math_element is not None + else None + ) + arguments: List[str] = [] + body = math_element + if lambda_element is not None: + for bvar in _children(lambda_element, "bvar"): + argument = list(bvar)[0] if list(bvar) else None + name = _mathml_to_formula(argument) + if name: + arguments.append(name) + children = list(lambda_element) + body = children[-1] if children else None + result[item_id] = SBMLFunctionDefinition( + id=item_id, + name=str(_attribute(item, "name", item_id) or item_id), + math=SBMLParser._normalize_formula_identifiers( + _mathml_to_formula(body), parameter_aliases + ), + arguments=arguments, + ) + return result + + @staticmethod + def _parse_xml_events( + model: Any, parameter_aliases: Optional[Dict[str, str]] = None + ) -> List[SBMLEvent]: + result: List[SBMLEvent] = [] + for index, item in enumerate( + SBMLParser._xml_items(model, "listOfEvents", "event") + ): + trigger = _first_child(item, "trigger") + assignments_parent = _first_child(item, "listOfEventAssignments") + assignments = [] + if assignments_parent is not None: + for assignment in _children(assignments_parent, "eventAssignment"): + assignments.append( + ( + str(_attribute(assignment, "variable", "") or ""), + SBMLParser._normalize_formula_identifiers( + SBMLParser._xml_math(assignment), parameter_aliases + ), + ) + ) + priority = _first_child(item, "priority") + result.append( + SBMLEvent( + id=str( + _attribute(item, "id", f"event_{index + 1}") + or f"event_{index + 1}" + ), + name=str( + _attribute( + item, "name", _attribute(item, "id", f"event_{index + 1}") + ) + or f"event_{index + 1}" + ), + trigger=SBMLParser._normalize_formula_identifiers( + SBMLParser._xml_math(trigger), parameter_aliases + ), + delay=SBMLParser._normalize_formula_identifiers( + SBMLParser._xml_math(_first_child(item, "delay")), + parameter_aliases, + ) + or None, + use_values_from_trigger_time=_bool( + _attribute(item, "useValuesFromTriggerTime"), True + ), + assignments=assignments, + trigger_initial_value=( + _bool(_attribute(trigger, "initialValue"), True) + if trigger is not None + else None + ), + trigger_persistent=( + _bool(_attribute(trigger, "persistent"), True) + if trigger is not None + else None + ), + priority=SBMLParser._normalize_formula_identifiers( + SBMLParser._xml_math(priority), parameter_aliases + ) + or None, + ) + ) + return result + + @staticmethod + def _parse_xml_initial_assignments( + model: Any, parameter_aliases: Optional[Dict[str, str]] = None + ) -> List[SBMLInitialAssignment]: + result: List[SBMLInitialAssignment] = [] + for item in SBMLParser._xml_items( + model, "listOfInitialAssignments", "initialAssignment" + ): + symbol = _attribute(item, "symbol") + if symbol is not None: + result.append( + SBMLInitialAssignment( + symbol=str(symbol), + math=SBMLParser._normalize_formula_identifiers( + SBMLParser._xml_math(item), parameter_aliases + ), + ) + ) + return result + + @staticmethod + def _parse_xml_units(model: Any) -> Dict[str, Any]: + result: Dict[str, Any] = OrderedDict() + for definition in SBMLParser._xml_items( + model, "listOfUnitDefinitions", "unitDefinition" + ): + definition_id = str(_attribute(definition, "id", "") or "") + if not definition_id: + continue + units = [] + parent = _first_child(definition, "listOfUnits") + if parent is not None: + for unit in _children(parent, "unit"): + units.append( + { + "kind": _attribute(unit, "kind", ""), + "scale": int(_float(_attribute(unit, "scale"), 0)), + "exponent": _float(_attribute(unit, "exponent"), 1), + "multiplier": _float(_attribute(unit, "multiplier"), 1), + } + ) + result[definition_id] = units + return result + + @staticmethod + def _raw_elements(root: Any) -> Dict[str, Dict[str, Dict[str, str]]]: + result: Dict[str, Dict[str, Dict[str, str]]] = { + "compartment": {}, + "species": {}, + "reaction": {}, + } + for element in root.iter(): + kind = _local_name(element.tag) + element_id = element.attrib.get("id") + if kind in result and element_id: + result[kind][element_id] = dict(element.attrib) + return result + + @staticmethod + def _parse_compartments( + model: Any, raw: Dict[str, Dict[str, str]] + ) -> Mapping[str, SBMLCompartment]: + result: Dict[str, SBMLCompartment] = OrderedDict() + for index in range(model.getNumCompartments()): + item = model.getCompartment(index) + item_id = str(item.getId()) + attrs = raw.get(item_id, {}) + result[item_id] = SBMLCompartment( + id=item_id, + name=str(item.getName() or item_id), + spatial_dimensions=_float(item.getSpatialDimensions(), 3), + size=_float(item.getSize(), 1), + units=str(item.getUnits() or ""), + constant=( + bool(item.getConstant()) if hasattr(item, "getConstant") else True + ), + outside=str(item.getOutside() or attrs.get("outside") or "") or None, + compartment_type=attrs.get("compartmentType"), + size_set="size" in attrs or "volume" in attrs, + ) + return result + + @staticmethod + def _parse_species( + model: Any, raw: Dict[str, Dict[str, str]], libsbml: Any + ) -> Mapping[str, SBMLSpecies]: + result: Dict[str, SBMLSpecies] = OrderedDict() + for index in range(model.getNumSpecies()): + item = model.getSpecies(index) + item_id = str(item.getId()) + attrs = raw.get(item_id, {}) + result[item_id] = SBMLSpecies( + id=item_id, + name=str(item.getName() or item_id), + compartment=str(item.getCompartment() or ""), + initial_concentration=_float(item.getInitialConcentration(), 0), + initial_amount=_float(item.getInitialAmount(), 0), + substance_units=( + str(item.getSubstanceUnits() or "") + if hasattr(item, "getSubstanceUnits") + else "" + ), + has_only_substance_units=bool(item.getHasOnlySubstanceUnits()), + boundary_condition=bool(item.getBoundaryCondition()), + constant=bool(item.getConstant()), + annotations=SBMLParser._parse_annotations(item), + initial_amount_set="initialAmount" in attrs, + initial_concentration_set="initialConcentration" in attrs, + sbo_term=attrs.get("sboTerm"), + conversion_factor=attrs.get("conversionFactor"), + charge=_float(attrs["charge"]) if "charge" in attrs else None, + species_type=attrs.get("speciesType"), + ) + return result + + @staticmethod + def _parse_annotations(item: Any) -> List[AnnotationInfo]: + result: List[AnnotationInfo] = [] + if not hasattr(item, "getNumCVTerms"): + return result + for index in range(item.getNumCVTerms()): + term = item.getCVTerm(index) + qualifier_type = int(term.getQualifierType()) + resources = [ + str(term.getResourceURI(resource_index)) + for resource_index in range(term.getNumResources()) + ] + biological = None + model = None + if qualifier_type == 1 and hasattr(term, "getBiologicalQualifierType"): + biological = int(term.getBiologicalQualifierType()) + elif hasattr(term, "getModelQualifierType"): + model = int(term.getModelQualifierType()) + result.append(AnnotationInfo(qualifier_type, biological, model, resources)) + return result + + @staticmethod + def _parse_parameters(model: Any) -> Mapping[str, SBMLParameter]: + result: Dict[str, SBMLParameter] = OrderedDict() + for index in range(model.getNumParameters()): + item = model.getParameter(index) + item_id = str(item.getId()) + result[item_id] = SBMLParameter( + id=item_id, + name=str(item.getName() or item_id), + value=_float(item.getValue(), 0), + units=str(item.getUnits() or "") if hasattr(item, "getUnits") else "", + constant=( + bool(item.getConstant()) if hasattr(item, "getConstant") else True + ), + scope="global", + ) + return result + + @staticmethod + def _parse_reference(ref: Any) -> SBMLSpeciesReference: + stoichiometry = _float(ref.getStoichiometry(), 1) + is_set = ( + bool(ref.isSetStoichiometry()) + if hasattr(ref, "isSetStoichiometry") + else stoichiometry != 1 + ) + return SBMLSpeciesReference( + species=str(ref.getSpecies()), + stoichiometry=stoichiometry, + constant=bool(ref.getConstant()) if hasattr(ref, "getConstant") else True, + id=( + str(ref.getId() or "") + if hasattr(ref, "getId") and ref.getId() + else None + ), + stoichiometry_set=is_set, + variable_stoichiometry=( + not bool(ref.getConstant()) if hasattr(ref, "getConstant") else False + ), + ) + + @staticmethod + def _parse_reactions( + model: Any, raw: Dict[str, Dict[str, str]], libsbml: Any + ) -> Mapping[str, SBMLReaction]: + result: Dict[str, SBMLReaction] = OrderedDict() + for index in range(model.getNumReactions()): + item = model.getReaction(index) + item_id = str(item.getId()) + kinetic = item.getKineticLaw() + law = None + if kinetic is not None: + math_node = kinetic.getMath() if hasattr(kinetic, "getMath") else None + math = _formula_from_math(math_node, libsbml) + if not math and hasattr(kinetic, "getFormula"): + math = str(kinetic.getFormula() or "").strip() + local_parameters: List[SBMLParameter] = [] + count = ( + kinetic.getNumLocalParameters() + if hasattr(kinetic, "getNumLocalParameters") + else 0 + ) + for local_index in range(count): + local = kinetic.getLocalParameter(local_index) + local_id = str(local.getId()) + local_parameters.append( + SBMLParameter( + id=local_id, + name=str(local.getName() or local_id), + value=_float(local.getValue(), 0), + units=( + str(local.getUnits() or "") + if hasattr(local, "getUnits") + else "" + ), + constant=True, + scope="local", + ) + ) + math_ml = ( + str(math_node.toMathML()) + if math_node is not None and hasattr(math_node, "toMathML") + else "" + ) + law = SBMLKineticLaw( + math=math, math_ml=math_ml, local_parameters=local_parameters + ) + + result[item_id] = SBMLReaction( + id=item_id, + name=str(item.getName() or item_id), + reversible=bool(item.getReversible()), + fast=bool(item.getFast()) if hasattr(item, "getFast") else False, + reactants=[ + SBMLParser._parse_reference(item.getReactant(i)) + for i in range(item.getNumReactants()) + ], + products=[ + SBMLParser._parse_reference(item.getProduct(i)) + for i in range(item.getNumProducts()) + ], + modifiers=[ + SBMLModifierSpeciesReference(str(item.getModifier(i).getSpecies())) + for i in range(item.getNumModifiers()) + ], + kinetic_law=law, + compartment=str( + item.getCompartment() + or raw.get(item_id, {}).get("compartment") + or "" + ) + or None, + conversion_factor=raw.get(item_id, {}).get("conversionFactor"), + ) + return result + + @staticmethod + def _formula_for_sbase(item: Any, libsbml: Any) -> str: + math_node = item.getMath() if hasattr(item, "getMath") else None + formula = _formula_from_math(math_node, libsbml) + if not formula and hasattr(item, "getFormula"): + formula = str(item.getFormula() or "").strip() + return formula + + @staticmethod + def _parse_rules(model: Any, libsbml: Any) -> List[SBMLRule]: + result: List[SBMLRule] = [] + for index in range(model.getNumRules()): + item = model.getRule(index) + if item.isAssignment() if hasattr(item, "isAssignment") else False: + kind = "assignment" + elif item.isRate() if hasattr(item, "isRate") else False: + kind = "rate" + elif item.isAlgebraic() if hasattr(item, "isAlgebraic") else False: + kind = "algebraic" + else: + continue + result.append( + SBMLRule( + type=kind, + variable=( + str(item.getVariable() or "") + if hasattr(item, "getVariable") + else None + ), + math=SBMLParser._formula_for_sbase(item, libsbml), + ) + ) + return result + + @staticmethod + def _parse_functions( + model: Any, libsbml: Any + ) -> Mapping[str, SBMLFunctionDefinition]: + result: Dict[str, SBMLFunctionDefinition] = OrderedDict() + count = ( + model.getNumFunctionDefinitions() + if hasattr(model, "getNumFunctionDefinitions") + else 0 + ) + for index in range(count): + item = model.getFunctionDefinition(index) + item_id = str(item.getId()) + arguments = [] + for arg_index in range( + item.getNumArguments() if hasattr(item, "getNumArguments") else 0 + ): + argument = item.getArgument(arg_index) + arguments.append( + str(argument.getName() or argument.getCharacter() or "arg") + ) + result[item_id] = SBMLFunctionDefinition( + id=item_id, + name=str(item.getName() or item_id), + math=SBMLParser._formula_for_sbase(item, libsbml), + arguments=arguments, + ) + return result + + @staticmethod + def _parse_events(model: Any, libsbml: Any) -> List[SBMLEvent]: + result: List[SBMLEvent] = [] + count = model.getNumEvents() if hasattr(model, "getNumEvents") else 0 + for index in range(count): + item = model.getEvent(index) + trigger = item.getTrigger() if hasattr(item, "getTrigger") else None + delay = item.getDelay() if hasattr(item, "getDelay") else None + assignments = [] + for assignment_index in range(item.getNumEventAssignments()): + assignment = item.getEventAssignment(assignment_index) + assignments.append( + ( + str(assignment.getVariable()), + SBMLParser._formula_for_sbase(assignment, libsbml), + ) + ) + priority = item.getPriority() if hasattr(item, "getPriority") else None + result.append( + SBMLEvent( + id=str(item.getId() or f"event_{index + 1}"), + name=str(item.getName() or item.getId() or f"event_{index + 1}"), + trigger=SBMLParser._formula_for_sbase(trigger, libsbml), + delay=SBMLParser._formula_for_sbase(delay, libsbml) or None, + use_values_from_trigger_time=bool( + item.getUseValuesFromTriggerTime() + ), + assignments=assignments, + trigger_initial_value=( + bool(trigger.getInitialValue()) + if trigger is not None and hasattr(trigger, "getInitialValue") + else None + ), + trigger_persistent=( + bool(trigger.getPersistent()) + if trigger is not None and hasattr(trigger, "getPersistent") + else None + ), + priority=SBMLParser._formula_for_sbase(priority, libsbml) or None, + ) + ) + return result + + @staticmethod + def _parse_initial_assignments( + model: Any, libsbml: Any + ) -> List[SBMLInitialAssignment]: + result: List[SBMLInitialAssignment] = [] + count = ( + model.getNumInitialAssignments() + if hasattr(model, "getNumInitialAssignments") + else 0 + ) + for index in range(count): + item = model.getInitialAssignment(index) + result.append( + SBMLInitialAssignment( + symbol=str(item.getSymbol()), + math=SBMLParser._formula_for_sbase(item, libsbml), + ) + ) + return result + + @staticmethod + def _parse_units(model: Any) -> Mapping[str, Any]: + result: Dict[str, Any] = OrderedDict() + count = ( + model.getNumUnitDefinitions() + if hasattr(model, "getNumUnitDefinitions") + else 0 + ) + for index in range(count): + definition = model.getUnitDefinition(index) + units = [] + for unit_index in range(definition.getNumUnits()): + unit = definition.getUnit(unit_index) + units.append( + { + "kind": int(unit.getKind()), + "scale": int(unit.getScale()), + "exponent": int(unit.getExponent()), + "multiplier": _float(unit.getMultiplier(), 1), + } + ) + result[str(definition.getId())] = units + return result + + +def extract_uniprot_ids(resources: Iterable[str]) -> List[str]: + """Extract UniProt identifiers from MIRIAM/identifiers.org resources.""" + + result = [] + for resource in resources: + match = re.search(r"uniprot[:/]([A-Z0-9]+)", str(resource), re.IGNORECASE) + if match: + result.append(match.group(1)) + return result + + +def extract_go_terms(resources: Iterable[str]) -> List[str]: + result = [] + for resource in resources: + match = re.search(r"GO[:/](\d+)", str(resource), re.IGNORECASE) + if match: + result.append(f"GO:{match.group(1)}") + return result + + +extractGOTerms = extract_go_terms +extractUniProtIds = extract_uniprot_ids + + +__all__ = [ + "SBMLParser", + "extractGOTerms", + "extractUniProtIds", + "extract_go_terms", + "extract_uniprot_ids", +] diff --git a/python/bionetgen/atomizer/modern/rate_rule_constants.py b/python/bionetgen/atomizer/modern/rate_rule_constants.py new file mode 100644 index 00000000..f09e75e7 --- /dev/null +++ b/python/bionetgen/atomizer/modern/rate_rule_constants.py @@ -0,0 +1,20 @@ +"""Prefixes shared by the Playground-derived rate-rule writers.""" + +# The BNGL writer and the reverse SBML writer must recognize the same +# synthetic names. Keep the source-defined prefixes in one module so a later +# writer port cannot silently change only one side of the round trip. +RATE_RULE_META_PREFIX = "__rate_rule__" +SYNTH_RATE_RULE_SPECIES_PREFIX = "__rate_rule_state__" + +# The TypeScript BNGL writer keeps these directional prefixes beside its +# writer implementation. They are centralized here because the Python writer +# emits the same source/sink pair. +RATE_RULE_POS_PREFIX = "__rate_rule_pos__" +RATE_RULE_NEG_PREFIX = "__rate_rule_neg__" + +__all__ = [ + "RATE_RULE_META_PREFIX", + "RATE_RULE_NEG_PREFIX", + "RATE_RULE_POS_PREFIX", + "SYNTH_RATE_RULE_SPECIES_PREFIX", +] diff --git a/python/bionetgen/atomizer/modern/rulifier.py b/python/bionetgen/atomizer/modern/rulifier.py new file mode 100644 index 00000000..1d8e097b --- /dev/null +++ b/python/bionetgen/atomizer/modern/rulifier.py @@ -0,0 +1,351 @@ +"""Rule-based analysis helpers from the Playground Atomizer. + +The Rulifier operates on the modern structured ``Rule`` representation. It +keeps transformation centers, state transitions, redundant-rule groups, and +rate-law diagnostics explicit so callers can audit each generalization. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import asdict, dataclass +from typing import Dict, List, Optional, Set, Tuple + +from .structures import Action, Rule, Species + + +@dataclass +class ComponentGroup: + name: str + molecule: str + states: List[str] + patterns: List[Species] + + +@dataclass +class TransformationCenter: + action: str + site1: str + site2: str + molecules: List[str] + + +@dataclass +class TransformationContext: + patterns: List[Species] + modifiers: List[str] + + +@dataclass +class RuleGrouping: + center: TransformationCenter + context: TransformationContext + rate: str + rules: List[Rule] + + +@dataclass +class StateTransition: + from_state: str + to_state: str + molecule: str + component: str + rate: str + rule: Rule + + +@dataclass +class StateTransitionDiagram: + states: Set[str] + transitions: List[StateTransition] + initial_state: str + + +def _parse_action_site( + site_id: str, patterns: List[Species] +) -> Optional[Tuple[str, str]]: + for pattern in patterns: + for molecule in pattern.molecules: + for component in molecule.components: + if component.idx == site_id: + return molecule.name, component.name + return None + + +def _find_component_state( + patterns: List[Species], molecule_name: str, component_name: str +) -> Optional[str]: + for pattern in patterns: + for molecule in pattern.molecules: + if molecule.name != molecule_name: + continue + for component in molecule.components: + if component.name == component_name: + return component.active_state or None + return None + + +def build_state_transition_diagram( + rules: List[Rule], molecule_name: str, component_name: str +) -> StateTransitionDiagram: + """Build a state graph for one molecule component across reaction rules.""" + + states: Set[str] = set() + transitions: List[StateTransition] = [] + initial_state = "0" + + for rule in rules: + for action in rule.actions: + if action.action != "StateChange": + continue + site_info = _parse_action_site(action.site1, rule.reactants) + if site_info != (molecule_name, component_name): + continue + + reactant_state = _find_component_state( + rule.reactants, molecule_name, component_name + ) + product_state = _find_component_state( + rule.products, molecule_name, component_name + ) + if reactant_state and product_state and reactant_state != product_state: + states.update((reactant_state, product_state)) + transitions.append( + StateTransition( + from_state=reactant_state, + to_state=product_state, + molecule=molecule_name, + component=component_name, + rate=rule.rates[0] if rule.rates else "1", + rule=rule, + ) + ) + + if "0" in states: + initial_state = "0" + elif "U" in states: + initial_state = "U" + elif states: + initial_state = next(iter(states)) + + return StateTransitionDiagram(states, transitions, initial_state) + + +def extract_transformation_center(rule: Rule) -> TransformationCenter: + """Extract the first action and all molecules touched by a rule.""" + + action = rule.actions[0] if rule.actions else Action() + molecules: List[str] = [] + for pattern in [*rule.reactants, *rule.products]: + for molecule in pattern.molecules: + if molecule.name not in molecules: + molecules.append(molecule.name) + return TransformationCenter( + action=action.action, + site1=action.site1, + site2=action.site2, + molecules=molecules, + ) + + +def _extract_transformation_context(rule: Rule) -> TransformationContext: + patterns: List[Species] = [] + for reactant in rule.reactants: + for product in rule.products: + if str(reactant) == str(product): + patterns.append(reactant) + return TransformationContext(patterns=patterns, modifiers=[]) + + +def species_equal(first: Species, second: Species) -> bool: + """Compare species using the same rendered-pattern identity as the port.""" + + return str(first) == str(second) + + +def group_by_reaction_center(rules: List[Rule]) -> Dict[str, List[Rule]]: + """Group rules by action sites and participating molecule names.""" + + groups: Dict[str, List[Rule]] = {} + for rule in rules: + center = extract_transformation_center(rule) + key = json.dumps(asdict(center), separators=(",", ":")) + groups.setdefault(key, []).append(rule) + return groups + + +def find_redundant_rules(rules: List[Rule]) -> Dict[str, List[Rule]]: + """Find rules sharing both transformation center and unchanged context.""" + + redundant: Dict[str, List[Rule]] = {} + for center_key, center_rules in group_by_reaction_center(rules).items(): + if len(center_rules) <= 1: + continue + context_signatures: Dict[str, List[Rule]] = {} + for rule in center_rules: + context = _extract_transformation_context(rule) + context_key = json.dumps( + sorted(str(pattern) for pattern in context.patterns), + separators=(",", ":"), + ) + context_signatures.setdefault(context_key, []).append(rule) + for context_key, context_rules in context_signatures.items(): + if len(context_rules) > 1: + redundant[f"{center_key}_{context_key}"] = context_rules + return redundant + + +def _generalize_species(patterns: List[Species]) -> Species: + if not patterns: + return Species() + base_pattern = patterns[0].copy() + states: Dict[str, Dict[str, Set[str]]] = {} + for pattern in patterns: + for molecule in pattern.molecules: + molecule_states = states.setdefault(molecule.name, {}) + for component in molecule.components: + if component.active_state: + molecule_states.setdefault(component.name, set()).add( + component.active_state + ) + + for molecule in base_pattern.molecules: + molecule_states = states.get(molecule.name, {}) + for component in molecule.components: + component_states = molecule_states.get(component.name, set()) + if len(component_states) > 1: + component.active_state = "" + return base_pattern + + +def _generalize_rule(rules: List[Rule]) -> Rule: + if not rules: + return Rule() + base_rule = rules[0] + generalized = Rule(f"{base_rule.label}_generalized") + for index in range(len(base_rule.reactants)): + generalized.add_reactant( + _generalize_species( + [rule.reactants[index] for rule in rules if index < len(rule.reactants)] + ) + ) + for index in range(len(base_rule.products)): + generalized.add_product( + _generalize_species( + [rule.products[index] for rule in rules if index < len(rule.products)] + ) + ) + if base_rule.rates: + generalized.add_rate(base_rule.rates[0]) + generalized.bidirectional = base_rule.bidirectional + generalized.actions = list(base_rule.actions) + return generalized + + +def collapse_redundant_rules(rules: List[Rule]) -> List[Rule]: + """Replace each redundant group with one generalized rule.""" + + collapsed: List[Rule] = [] + processed: Set[Rule] = set() + for group in find_redundant_rules(rules).values(): + collapsed.append(_generalize_rule(group)) + processed.update(group) + collapsed.extend(rule for rule in rules if rule not in processed) + return collapsed + + +_RESERVED_RATE_WORDS = { + "if", + "then", + "else", + "ln", + "log", + "exp", + "sin", + "cos", + "tan", + "sqrt", + "abs", + "min", + "max", +} + + +def extract_parameters(rules: List[Rule]) -> Set[str]: + """Extract identifier-like rate terms excluding common math functions.""" + + parameters: Set[str] = set() + for rule in rules: + for rate in rule.rates: + for parameter in re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", rate): + if parameter.lower() not in _RESERVED_RATE_WORDS: + parameters.add(parameter) + return parameters + + +def analyze_rate_law(rate: str, reactant_species: List[str]) -> Dict[str, object]: + """Estimate reaction order and mass-action status from a rate expression.""" + + remaining_rate = rate + order = 0 + for species in reactant_species: + pattern = re.compile(rf"\b{species}\b") + matches = pattern.findall(remaining_rate) + if matches: + order += len(matches) + remaining_rate = pattern.sub("1", remaining_rate) + + cleaned = re.sub(r"[\s*()1]", "", remaining_rate) + is_mass_action = bool( + re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", cleaned) + or re.fullmatch(r"[\d.e+-]+", cleaned) + ) + return { + "order": order, + "mass_action": is_mass_action, + "rate_constant": cleaned if is_mass_action else None, + } + + +# Source-facing aliases ease migration of callers that still use the +# Playground's camelCase names. +buildStateTransitionDiagram = build_state_transition_diagram +groupByReactionCenter = group_by_reaction_center +groupRulesByCenter = group_by_reaction_center +findRedundantRules = find_redundant_rules +collapseRedundantRules = collapse_redundant_rules +extractParameters = extract_parameters +analyzeRateLaw = analyze_rate_law +extractTransformationCenter = extract_transformation_center +extractTransformationContext = _extract_transformation_context +speciesEqual = species_equal + + +__all__ = [ + "Action", + "ComponentGroup", + "RuleGrouping", + "StateTransition", + "StateTransitionDiagram", + "TransformationCenter", + "TransformationContext", + "analyzeRateLaw", + "analyze_rate_law", + "buildStateTransitionDiagram", + "build_state_transition_diagram", + "collapseRedundantRules", + "collapse_redundant_rules", + "extractParameters", + "extractTransformationCenter", + "extractTransformationContext", + "extract_parameters", + "extract_transformation_center", + "findRedundantRules", + "find_redundant_rules", + "groupByReactionCenter", + "groupRulesByCenter", + "group_by_reaction_center", + "speciesEqual", + "species_equal", +] diff --git a/python/bionetgen/atomizer/modern/structures.py b/python/bionetgen/atomizer/modern/structures.py new file mode 100644 index 00000000..09baab4d --- /dev/null +++ b/python/bionetgen/atomizer/modern/structures.py @@ -0,0 +1,899 @@ +"""Structured BNGL patterns used by the Playground atomizer port. + +The public TypeScript implementation keeps a deliberately small object model: +Species contain Molecules, Molecules contain Components, and Components carry +states and bonds. This module preserves that separation while making IDs +deterministic, which is useful for Python callers and reproducible output. +""" + +from __future__ import annotations + +import copy +import re +from collections import Counter +from dataclasses import dataclass +from typing import Iterable, List, Optional, Sequence, Tuple, Union + +Bond = Union[str, int] + + +def _bond_number(value: Bond) -> Optional[int]: + if value in ("+", "?"): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +class Component: + """A BNGL binding or modification site.""" + + def __init__( + self, + name: str, + idx: str = "", + bonds: Optional[Iterable[Bond]] = None, + states: Optional[Iterable[str]] = None, + ) -> None: + self.name = name + self.idx = idx or name + self.states = list(states or []) + self.bonds = list(bonds or []) + self.active_state = "" + + def copy(self) -> "Component": + result = Component( + self.name, self.idx, copy.deepcopy(self.bonds), copy.deepcopy(self.states) + ) + result.active_state = self.active_state + return result + + def add_state(self, state: str, update: bool = True) -> None: + if state not in self.states: + self.states.append(state) + if update: + self.set_active_state(state) + if "0" not in self.states: + self.states.append("0") + + def add_states(self, states: Iterable[str], update: bool = True) -> None: + for state in states: + if state not in self.states: + self.add_state(state, update) + + def add_bond(self, bond: Bond) -> None: + if all(str(existing) != str(bond) for existing in self.bonds): + self.bonds.append(bond) + + def set_active_state(self, state: str) -> bool: + if state: + if state not in self.states: + self.states.append(state) + self.active_state = state + else: + self.active_state = "" + return True + + def get_name(self) -> str: + return self.name + + def has_wildcard_bonds(self) -> bool: + return "+" in self.bonds or "?" in self.bonds + + def reset(self) -> None: + self.bonds = [] + if "0" in self.states: + self.active_state = "0" + + def get_rule_str(self) -> str: + result = self.name + if self.bonds: + result += "!" + "!".join(str(bond) for bond in self.bonds) + if self.active_state: + result += "~" + self.active_state + return result + + def get_total_str(self) -> str: + return self.name + "~" + "~".join(self.states) + + def str2(self) -> str: + result = self.name + if self.bonds: + result += "!" + "!".join(str(bond) for bond in self.bonds) + if self.states: + result += "~" + "~".join(self.states) + if result and result[0].isdigit(): + result = "_" + result + return result + + def __str__(self) -> str: + return self.get_rule_str() + + def hash(self) -> str: + return self.name + + +class Molecule: + """A molecule instance or molecule type.""" + + def __init__(self, name: str, idx: str = "") -> None: + self.name = name + self.idx = idx or name + self.components: List[Component] = [] + self.compartment = "" + self.true_name = "" + self.unique_identifier = 0 + + def copy(self) -> "Molecule": + result = Molecule(self.name, self.idx) + result.compartment = self.compartment + result.true_name = self.true_name + result.unique_identifier = self.unique_identifier + result.components = [component.copy() for component in self.components] + return result + + def add_component(self, component: Component, overlap: bool = False) -> None: + if not overlap: + self.components.append(component) + else: + existing = self.get_component(component.name) + if existing is None: + self.components.append(component) + else: + existing.add_states(component.states) + self.components.sort(key=lambda item: item.name) + + def set_compartment(self, compartment: str) -> None: + self.compartment = compartment + + def get_component_by_id(self, idx: str) -> Optional[Component]: + return next( + (component for component in self.components if component.idx == idx), None + ) + + def get_bond_numbers(self) -> List[int]: + return [ + number + for component in self.components + for bond in component.bonds + if (number := _bond_number(bond)) is not None + ] + + def get_component(self, component_name: str) -> Optional[Component]: + return next( + ( + component + for component in self.components + if component.name == component_name + ), + None, + ) + + def remove_component(self, component_name: str) -> None: + self.components = [ + component + for component in self.components + if component.name != component_name + ] + + def remove_components(self, components: Iterable[Component]) -> None: + for element in components: + if element in self.components: + self.components.remove(element) + + def add_bond(self, component_name: str, bond_name: int) -> None: + while bond_name in self.get_bond_numbers(): + bond_name += 1 + component = self.get_component(component_name) + if component is not None: + component.add_bond(bond_name) + + def get_component_with_bonds(self) -> List[Component]: + return [component for component in self.components if component.bonds] + + def contains(self, component_name: str) -> bool: + return self.get_component(component_name) is not None + + def has_wildcard_bonds(self) -> bool: + return any(component.has_wildcard_bonds() for component in self.components) + + def extend(self, molecule: "Molecule") -> None: + component_map = {component.name: component for component in self.components} + for incoming in molecule.components: + existing = component_map.get(incoming.name) + if existing is None: + copied = incoming.copy() + self.components.append(copied) + component_map[copied.name] = copied + else: + for bond in incoming.bonds: + existing.add_bond(bond) + existing.add_states(incoming.states) + self.components.sort(key=lambda item: item.name) + + def update(self, molecule: "Molecule") -> None: + names = {component.name for component in self.components} + for incoming in molecule.components: + if incoming.name not in names: + self.components.append(incoming.copy()) + names.add(incoming.name) + self.components.sort(key=lambda item: item.name) + + def reset(self) -> None: + for component in self.components: + component.reset() + + def distance(self, other: "Molecule") -> int: + distance = 10000 if self.name != other.name else 0 + for left, right in zip_longest(self.components, other.components): + if left is None or right is None: + distance += 1 + continue + if left.bonds != right.bonds: + distance += 1 + if left.active_state != right.active_state: + distance += 1 + return distance + + def compare(self, other: "Molecule") -> None: + self.components.sort(key=lambda item: item.name) + other.components.sort(key=lambda item: item.name) + for left, right in zip(self.components, other.components): + if left.active_state != right.active_state: + left.active_state = "" + + def to_string(self, full: bool = False) -> str: + self.components.sort(key=lambda item: item.name) + interesting = ( + self.components + if full + else [ + component + for component in self.components + if component.bonds or component.active_state + ] + ) + result = self.name + if interesting: + result += "(" + ",".join(str(component) for component in interesting) + ")" + elif full and self.components: + result += ( + "(" + ",".join(str(component) for component in self.components) + ")" + ) + if self.compartment: + result += "@" + self.compartment + return result + + def str2(self) -> str: + self.components.sort(key=lambda item: item.name) + result = self.name.replace("-", "_") + if result and result[0].isdigit(): + result = "m__" + result + if self.components: + result += ( + "(" + ",".join(component.str2() for component in self.components) + ")" + ) + return result + + def str3(self) -> str: + return ( + self.name + "(" + self.components[0].name + ")" + if self.components + else self.name + "()" + ) + + def __str__(self) -> str: + return self.to_string() + + +def zip_longest(left: Sequence[Component], right: Sequence[Component]): + length = max(len(left), len(right)) + for index in range(length): + yield ( + left[index] if index < len(left) else None, + right[index] if index < len(right) else None, + ) + + +class Species: + """A connected or disconnected BNGL species pattern.""" + + def __init__(self) -> None: + self.molecules: List[Molecule] = [] + self.bond_numbers: List[int] = [] + self.bonds: List[Tuple[str, str]] = [] + self.identifier = 0 + self.idx = "" + + def get_bond_numbers(self) -> List[int]: + return [0] + [ + number + for molecule in self.molecules + for number in molecule.get_bond_numbers() + ] + + def copy(self) -> "Species": + result = Species() + result.bond_numbers = copy.deepcopy(self.bond_numbers) + result.bonds = copy.deepcopy(self.bonds) + result.identifier = self.identifier + result.idx = self.idx + result.molecules = [molecule.copy() for molecule in self.molecules] + return result + + def get_molecule_by_id(self, idx: str) -> Optional[Molecule]: + return next( + (molecule for molecule in self.molecules if molecule.idx == idx), None + ) + + def add_molecule( + self, molecule: Molecule, concatenate: bool = False, iteration: int = 1 + ) -> None: + if not concatenate: + self.molecules.append(molecule) + return + count = 0 + for existing in self.molecules: + if existing.name == molecule.name: + count += 1 + if count == iteration: + existing.extend(molecule) + return + self.molecules.append(molecule) + + def add_compartment(self, tags: str) -> None: + for molecule in self.molecules: + if not molecule.compartment: + molecule.set_compartment(tags) + + def delete_molecule(self, molecule_name: str) -> None: + dead = next( + (molecule for molecule in self.molecules if molecule.name == molecule_name), + None, + ) + if dead is None: + return + removed = set(dead.get_bond_numbers()) + self.molecules.remove(dead) + for molecule in self.molecules: + for component in molecule.components: + component.bonds = [ + bond + for bond in component.bonds + if _bond_number(bond) not in removed + ] + + def get_molecule(self, molecule_name: str) -> Optional[Molecule]: + return next( + (molecule for molecule in self.molecules if molecule.name == molecule_name), + None, + ) + + def get_size(self) -> int: + return len(self.molecules) + + def get_molecule_names(self) -> List[str]: + return [molecule.name for molecule in self.molecules] + + def contains(self, molecule_name: str) -> bool: + return any(molecule.name == molecule_name for molecule in self.molecules) + + def has_wildcard_bonds(self) -> bool: + return any(molecule.has_wildcard_bonds() for molecule in self.molecules) + + def extract_atomic_patterns( + self, + action: str, + site1: str, + site2: str, + differentiate_dimers: bool = False, + ) -> "AtomicPatternResult": + """Extract component and bonded patterns for rule-center analysis.""" + + atomic_patterns: dict[str, Species] = {} + bonded_patterns: dict[str, Species] = {} + reaction_center: List[Species] = [] + context: List[Species] = [] + + name_counter = Counter(molecule.name for molecule in self.molecules) + name_counter_copy = Counter(molecule.name for molecule in self.molecules) + self.sort() + + for molecule in self.molecules: + molecule_counter = ( + name_counter[molecule.name] - name_counter_copy[molecule.name] + ) + name_counter_copy[molecule.name] -= 1 + mol_name = ( + f"{molecule.name}%{molecule_counter}" + if differentiate_dimers + else molecule.name + ) + + for component in molecule.components: + if component.active_state != "": + species_structure = Species() + species_structure.bonds = list(self.bonds) + molecule_structure = Molecule(mol_name, molecule.idx) + component_structure = Component(component.name, component.idx) + component_structure.add_state(component.active_state) + component_structure.active_state = component.active_state + molecule_structure.add_component(component_structure) + species_structure.add_molecule(molecule_structure) + + if ( + component_structure.idx in {site1, site2} + and action == "StateChange" + ): + reaction_center.append(species_structure) + else: + context.append(species_structure) + atomic_patterns[str(species_structure)] = species_structure + + species_structure = Species() + species_structure.bonds = list(self.bonds) + molecule_structure = Molecule(mol_name, molecule.idx) + component_structure = Component(component.name, component.idx) + molecule_structure.add_component(component_structure) + species_structure.add_molecule(molecule_structure) + + if not component.bonds: + atomic_patterns[str(species_structure)] = species_structure + else: + bond_key = str(component.bonds[0]) + component_structure.add_bond("+" if bond_key == "+" else 1) + if bond_key not in bonded_patterns: + bonded_patterns[bond_key] = species_structure + elif bond_key != "+" or not bonded_patterns[bond_key].molecules: + bonded_patterns[bond_key].add_molecule(molecule_structure) + + if ( + component_structure.idx in {site1, site2} + and action != "StateChange" + ): + reaction_center.append(species_structure) + elif component.bonds or component.active_state == "": + context.append(species_structure) + + atomic_patterns.update( + (str(value), value) for value in bonded_patterns.values() + ) + reaction_center_strings = [ + str(value) for value in reaction_center if str(value) in atomic_patterns + ] + context_strings = [ + str(value) for value in context if str(value) in atomic_patterns + ] + return AtomicPatternResult( + atomic_patterns=atomic_patterns, + reaction_center=reaction_center_strings, + context=context_strings, + ) + + def list_of_bonds( + self, name_dict: Mapping[str, str] + ) -> Dict[str, Dict[str, List[Tuple[str, str]]]]: + """Return bonds indexed by molecule and component display names.""" + + result: Dict[str, Dict[str, List[Tuple[str, str]]]] = {} + for first, second in self.bonds: + molecule1 = re.sub(r"_C[^_]*$", "", first) + molecule2 = re.sub(r"_C[^_]*$", "", second) + molecule1_name = name_dict.get(molecule1, molecule1) + molecule2_name = name_dict.get(molecule2, molecule2) + first_name = name_dict.get(first, first) + second_name = name_dict.get(second, second) + result.setdefault(molecule1_name, {})[first_name] = [ + (molecule2_name, second_name) + ] + result.setdefault(molecule2_name, {})[second_name] = [ + (molecule1_name, first_name) + ] + return result + + def extend(self, species: "Species", update: bool = True) -> None: + if len(self.molecules) == len(species.molecules): + left = sorted( + self.molecules, + key=lambda molecule: (len(molecule.components), molecule.name), + ) + right = sorted( + species.molecules, + key=lambda molecule: (len(molecule.components), molecule.name), + ) + for current, incoming in zip(left, right): + incoming_counts = Counter( + component.name for component in incoming.components + ) + current_counts = Counter( + component.name for component in current.components + ) + existing = {} + for component in current.components: + existing.setdefault(component.name, component) + for component in incoming.components: + if current_counts[component.name] < incoming_counts[component.name]: + copied = component.copy() + current.components.append(copied) + current_counts[component.name] += 1 + existing.setdefault(component.name, copied) + else: + target = existing.get(component.name) + if target is not None: + target.add_states(component.states, update) + return + + for incoming in species.molecules: + candidates = [ + molecule + for molecule in self.molecules + if molecule.name == incoming.name + ] + if not candidates: + self.add_molecule(incoming.copy()) + continue + target = max( + candidates, + key=lambda molecule: self._sequence_match_ratio( + [ + str(bond) + for component in molecule.components + for bond in component.bonds + ], + [ + str(bond) + for component in incoming.components + for bond in component.bonds + ], + ), + ) + for component in incoming.components: + existing = target.get_component(component.name) + if existing is None: + target.add_component(component.copy(), update) + else: + existing.add_states(component.states, update) + + @staticmethod + def _sequence_match_ratio( + left: Sequence[Union[str, int]], right: Sequence[Union[str, int]] + ) -> float: + """Match the Playground's inclusion-based molecule similarity score.""" + + if not left and not right: + return 1.0 + if not left or not right: + return 0.0 + matches = sum(1 for item in left if item in right) + return (2 * matches) / (len(left) + len(right)) + + def update_bonds(self, bond_numbers: Iterable[int]) -> None: + offset = max(list(bond_numbers) or [0]) + 1 + for molecule in self.molecules: + for component in molecule.components: + component.bonds = [ + bond if bond in ("+", "?") else _bond_number(bond) + offset + for bond in component.bonds + ] + + def delete_bond(self, molecule_pair: Iterable[str]) -> None: + names = list(molecule_pair) + for molecule in self.molecules: + if molecule.name not in names: + continue + partners = list(names) + partners.remove(molecule.name) + partner_names = {str(partner).lower() for partner in partners} + for component in molecule.components: + if component.name.lower() in partner_names: + component.bonds = [] + + def append(self, species: "Species") -> None: + incoming = species.copy() + incoming.update_bonds(self.get_bond_numbers()) + self.molecules.extend(incoming.molecules) + + def sort(self) -> None: + def key(molecule: Molecule): + bonds = molecule.get_bond_numbers() + active = sum( + 1 + for component in molecule.components + if component.active_state not in ("", "0") + ) + return ( + -len(molecule.components), + min(bonds or [999]), + -len(molecule.get_component_with_bonds()), + -active, + len(molecule.to_string()), + molecule.to_string(), + ) + + self.molecules.sort(key=key) + + def renumber_bonds(self) -> None: + mapping = {} + next_bond = 1 + for molecule in self.molecules: + for component in molecule.components: + for index, bond in enumerate(component.bonds): + if bond in ("+", "?"): + continue + key = str(bond) + if key not in mapping: + mapping[key] = next_bond + next_bond += 1 + component.bonds[index] = mapping[key] + + def to_string(self, full: bool = False) -> str: + self.sort() + return ".".join( + molecule.to_string(full).replace("-", "_") for molecule in self.molecules + ) + + def str2(self) -> str: + self.sort() + return ".".join( + molecule.str2().replace("-", "_") for molecule in self.molecules + ) + + def reset(self) -> None: + for molecule in self.molecules: + molecule.reset() + + def __str__(self) -> str: + return self.to_string() + + def __eq__(self, other: object) -> bool: + return isinstance(other, Species) and str(self) == str(other) + + +class Action: + """A state, bond, or molecule operation attached to a reaction rule.""" + + def __init__(self) -> None: + self.action = "" + self.site1 = "" + self.site2 = "" + + def set_action(self, action: str, site1: str, site2: str = "") -> None: + self.action = action + self.site1 = site1 + self.site2 = site2 + + setAction = set_action + + def __str__(self) -> str: + return f"{self.action}, {self.site1}, {self.site2}" + + toString = __str__ + + +class Rule: + """A rule-based reaction with structured patterns and actions.""" + + def __init__(self, label: str = "") -> None: + self.label = label + self.reactants: List[Species] = [] + self.products: List[Species] = [] + self.rates: List[str] = [] + self.bidirectional = False + self.actions: List[Action] = [] + self.mapping: List[Tuple[str, str]] = [] + + def add_reactant(self, reactant: Species) -> None: + self.reactants.append(reactant) + + def add_product(self, product: Species) -> None: + self.products.append(product) + + def add_reactant_list(self, reactants: Iterable[Species]) -> None: + self.reactants.extend(reactants) + + def add_product_list(self, products: Iterable[Species]) -> None: + self.products.extend(products) + + def add_rate(self, rate: str) -> None: + self.rates.append(rate) + + def add_mapping(self, mapping: Tuple[str, str]) -> None: + self.mapping.append(mapping) + + def add_mapping_list(self, mappings: Iterable[Tuple[str, str]]) -> None: + self.mapping.extend(mappings) + + def add_action_list(self, actions: Iterable[Action]) -> None: + self.actions.extend(actions) + + addReactant = add_reactant + addProduct = add_product + addReactantList = add_reactant_list + addProductList = add_product_list + addRate = add_rate + addMapping = add_mapping + addMappingList = add_mapping_list + addActionList = add_action_list + + def __str__(self) -> str: + label = f"{self.label}: " if self.label else "" + arrow = " <-> " if self.bidirectional else " -> " + return ( + label + + " + ".join(str(item) for item in self.reactants) + + arrow + + " + ".join(str(item) for item in self.products) + + " " + + ",".join(self.rates) + ) + + toString = __str__ + + +@dataclass +class AtomicPatternResult: + atomic_patterns: Dict[str, Species] + reaction_center: List[str] + context: List[str] + + @property + def atomicPatterns(self) -> Dict[str, Species]: + return self.atomic_patterns + + @property + def reactionCenter(self) -> List[str]: + return self.reaction_center + + +class Databases: + """Container for translation and annotation lookup tables.""" + + def __init__(self) -> None: + self.translator: Dict[str, Species] = {} + self.synthesis_database: Dict[str, object] = {} + self.catalysis_database: Dict[str, object] = {} + self.raw_database: Dict[str, object] = {} + self.label_dictionary: Dict[str, object] = {} + self.synthesis_database2: Dict[str, object] = {} + + def get_raw_database(self) -> Dict[str, object]: + return self.raw_database + + def get_label_dictionary(self) -> Dict[str, object]: + return self.label_dictionary + + def add2_label_dictionary(self, key: Iterable[str], value: object) -> None: + self.label_dictionary[",".join(sorted(key))] = value + + def get_translator(self) -> Dict[str, Species]: + return self.translator + + getRawDatabase = get_raw_database + getLabelDictionary = get_label_dictionary + add2LabelDictionary = add2_label_dictionary + getTranslator = get_translator + + +class States: + """Simple state descriptor retained for source compatibility.""" + + def __init__(self, name: str = "", idx: str = "") -> None: + self.name = name + self.idx = idx + + +def _split_top_level(value: str, separator: str) -> List[str]: + parts: List[str] = [] + start = 0 + depth = 0 + for index, char in enumerate(value): + if char == "(": + depth += 1 + elif char == ")": + depth = max(depth - 1, 0) + elif char == separator and depth == 0: + parts.append(value[start:index]) + start = index + 1 + parts.append(value[start:]) + return parts + + +def read_from_string(pattern_str: str) -> Species: + """Parse a BNGL pattern, including species and molecule compartments.""" + + species = Species() + working = pattern_str.strip() + species_compartment = "" + prefix = re.match(r"^@([^:]+)::", working) + if prefix: + species_compartment = prefix.group(1) + working = working[prefix.end() :] + + for molecule_text in _split_top_level(working, "."): + molecule_text = molecule_text.strip() + if not molecule_text: + continue + match = re.match( + r"^([A-Za-z_][A-Za-z0-9_]*)(?:\(([^)]*)\))?(?:@([A-Za-z_][A-Za-z0-9_]*))?$", + molecule_text, + ) + if not match: + continue + molecule = Molecule(match.group(1)) + molecule.set_compartment(match.group(3) or species_compartment) + component_text = match.group(2) or "" + if component_text.strip(): + for component_def in _split_top_level(component_text, ","): + tokens = re.split(r"([~!])", component_def.strip()) + if not tokens or not tokens[0]: + continue + component = Component(tokens[0]) + for index in range(1, len(tokens) - 1, 2): + separator, value = tokens[index], tokens[index + 1] + if separator == "~": + component.add_state(value) + elif separator == "!": + component.add_bond(value) + molecule.add_component(component) + species.add_molecule(molecule) + return species + + +# Preserve the source module's parser helper spelling for direct callers. +readFromString = read_from_string + + +# Preserve the public method spellings from the TypeScript structures module +# alongside BNG3's snake_case Python API. +Component.addState = Component.add_state +Component.addStates = Component.add_states +Component.addBond = Component.add_bond +Component.setActiveState = Component.set_active_state +Component.getName = Component.get_name +Component.hasWildcardBonds = Component.has_wildcard_bonds +Component.getRuleStr = Component.get_rule_str +Component.getTotalStr = Component.get_total_str +Component.toString = Component.__str__ + +Molecule.addComponent = Molecule.add_component +Molecule.setCompartment = Molecule.set_compartment +Molecule.getComponentById = Molecule.get_component_by_id +Molecule.getBondNumbers = Molecule.get_bond_numbers +Molecule.getComponent = Molecule.get_component +Molecule.removeComponent = Molecule.remove_component +Molecule.removeComponents = Molecule.remove_components +Molecule.addBond = Molecule.add_bond +Molecule.getComponentWithBonds = Molecule.get_component_with_bonds +Molecule.hasWildcardBonds = Molecule.has_wildcard_bonds +Molecule.toString = Molecule.to_string + +Species.getBondNumbers = Species.get_bond_numbers +Species.getMoleculeById = Species.get_molecule_by_id +Species.addMolecule = Species.add_molecule +Species.addCompartment = Species.add_compartment +Species.deleteMolecule = Species.delete_molecule +Species.getMolecule = Species.get_molecule +Species.getSize = Species.get_size +Species.getMoleculeNames = Species.get_molecule_names +Species.hasWildCardBonds = Species.has_wildcard_bonds +Species.updateBonds = Species.update_bonds +Species.deleteBond = Species.delete_bond +Species.toString = Species.to_string +Species.extractAtomicPatterns = Species.extract_atomic_patterns +Species.listOfBonds = Species.list_of_bonds + + +__all__ = [ + "Action", + "AtomicPatternResult", + "Bond", + "Component", + "Databases", + "Molecule", + "Rule", + "Species", + "States", + "readFromString", + "read_from_string", +] diff --git a/python/bionetgen/atomizer/modern/types.py b/python/bionetgen/atomizer/modern/types.py new file mode 100644 index 00000000..ea23a3d2 --- /dev/null +++ b/python/bionetgen/atomizer/modern/types.py @@ -0,0 +1,845 @@ +"""Data contracts for the Playground-derived Python atomizer.""" + +from __future__ import annotations + +from collections import OrderedDict +from collections.abc import Mapping as MappingABC +from dataclasses import dataclass, field +from typing import Any, Dict, Iterator, List, Mapping, Optional, Tuple, Union + +from .structures import Species + + +@dataclass +class AnnotationInfo: + qualifier_type: int = -1 + biological_qualifier: Optional[int] = None + model_qualifier: Optional[int] = None + resources: List[str] = field(default_factory=list) + + +@dataclass +class SBMLCompartment: + id: str + name: str = "" + spatial_dimensions: float = 3 + size: float = 1 + units: str = "" + constant: bool = True + outside: Optional[str] = None + compartment_type: Optional[str] = None + size_set: bool = False + + +@dataclass +class SBMLParameter: + id: str + name: str = "" + value: float = 0 + units: str = "" + constant: bool = True + scope: str = "global" + + +@dataclass +class SBMLSpecies: + id: str + name: str = "" + compartment: str = "" + initial_concentration: float = 0 + initial_amount: float = 0 + substance_units: str = "" + has_only_substance_units: bool = False + boundary_condition: bool = False + constant: bool = False + annotations: List[AnnotationInfo] = field(default_factory=list) + # ``None`` represents an unavailable raw-SBML presence flag. This mirrors + # the Playground contract, where an absent flag falls back to a non-zero + # parsed value while an explicit false remains authoritative. + initial_amount_set: Optional[bool] = None + initial_concentration_set: Optional[bool] = None + sbo_term: Optional[str] = None + conversion_factor: Optional[str] = None + charge: Optional[float] = None + species_type: Optional[str] = None + + +@dataclass +class SBMLSpeciesReference: + species: str + stoichiometry: float = 1 + constant: bool = True + id: Optional[str] = None + stoichiometry_set: bool = False + variable_stoichiometry: bool = False + + +@dataclass +class SBMLModifierSpeciesReference: + species: str + + +class SBMLKineticLaw(dict): + """Mapping-compatible kinetic law with convenient attribute access.""" + + def __init__( + self, + math: str = "", + math_ml: str = "", + local_parameters: Optional[List[SBMLParameter]] = None, + ) -> None: + super().__init__( + math=math, mathML=math_ml, localParameters=local_parameters or [] + ) + self.math = math + self.math_ml = math_ml + self.local_parameters = local_parameters or [] + + +@dataclass +class SBMLReaction: + id: str + name: str = "" + reversible: bool = False + fast: bool = False + reactants: List[SBMLSpeciesReference] = field(default_factory=list) + products: List[SBMLSpeciesReference] = field(default_factory=list) + modifiers: List[Union[str, SBMLModifierSpeciesReference]] = field( + default_factory=list + ) + kinetic_law: Optional[Any] = None + compartment: Optional[str] = None + conversion_factor: Optional[str] = None + + +@dataclass +class SBMLRule: + type: str + variable: Optional[str] = None + math: str = "" + + +@dataclass +class SBMLFunctionDefinition: + id: str + name: str = "" + math: str = "" + arguments: List[str] = field(default_factory=list) + + +@dataclass +class SBMLEventAssignment(MappingABC[str, str]): + """Structured event assignment matching the Playground object contract.""" + + variable: str + math: str + + _FIELDS = ("variable", "math") + + def __getitem__(self, key: Union[str, int]) -> str: + if isinstance(key, int): + return (self.variable, self.math)[key] + if key not in self._FIELDS: + raise KeyError(key) + return getattr(self, key) + + def __iter__(self) -> Iterator[str]: + return iter(self._FIELDS) + + def __len__(self) -> int: + return len(self._FIELDS) + + def get(self, key: str, default: Any = None) -> Any: + return getattr(self, key, default) + + def __eq__(self, other: object) -> bool: + if isinstance(other, SBMLEventAssignment): + return (self.variable, self.math) == (other.variable, other.math) + if isinstance(other, MappingABC): + return self.variable == other.get("variable") and self.math == other.get( + "math" + ) + if isinstance(other, (tuple, list)): + return len(other) == 2 and (self.variable, self.math) == tuple(other) + return NotImplemented + + +def coerce_event_assignment(assignment: Any) -> SBMLEventAssignment: + """Convert legacy tuple/list or mapping assignments to source-shaped records.""" + + if isinstance(assignment, SBMLEventAssignment): + return assignment + if isinstance(assignment, MappingABC): + return SBMLEventAssignment( + variable=str(assignment.get("variable", "")), + math=str(assignment.get("math", "")), + ) + if isinstance(assignment, (tuple, list)): + return SBMLEventAssignment( + variable=str(assignment[0]) if len(assignment) > 0 else "", + math=str(assignment[1]) if len(assignment) > 1 else "", + ) + return SBMLEventAssignment(variable="", math="") + + +@dataclass +class SBMLEvent: + id: str + name: str = "" + trigger: str = "" + delay: Optional[str] = None + use_values_from_trigger_time: bool = True + assignments: List[Union[SBMLEventAssignment, Tuple[str, str]]] = field( + default_factory=list + ) + trigger_initial_value: Optional[bool] = None + trigger_persistent: Optional[bool] = None + priority: Optional[str] = None + + def __post_init__(self) -> None: + self.assignments = [ + coerce_event_assignment(assignment) for assignment in self.assignments + ] + + +@dataclass +class SBMLInitialAssignment: + symbol: str + math: str + + +@dataclass +class SBMLImportWarning(MappingABC[str, Any]): + """Structured import diagnostic matching the Playground record contract. + + The mapping view preserves BNG3's existing ``warning["category"]`` and + ``warning.get(...)`` callers while exposing the source-shaped attributes. + """ + + category: str + message: str + count: int + severity: str + + _FIELDS = ("category", "message", "count", "severity") + + def __getitem__(self, key: str) -> Any: + if key not in self._FIELDS: + raise KeyError(key) + return getattr(self, key) + + def __iter__(self) -> Iterator[str]: + return iter(self._FIELDS) + + def __len__(self) -> int: + return len(self._FIELDS) + + def get(self, key: str, default: Any = None) -> Any: + return getattr(self, key, default) + + +def coerce_import_warning(warning: Any) -> SBMLImportWarning: + """Convert legacy dictionary diagnostics to source-shaped records.""" + + if isinstance(warning, SBMLImportWarning): + return warning + if isinstance(warning, Mapping): + try: + count = int(warning.get("count", 1)) + except (TypeError, ValueError): + count = 1 + return SBMLImportWarning( + category=str(warning.get("category", "import")), + message=str(warning.get("message", "")), + count=count, + severity=str(warning.get("severity", "info")), + ) + return SBMLImportWarning( + category="import", message=str(warning), count=1, severity="info" + ) + + +@dataclass +class SBMLModel: + id: str + name: str = "" + compartments: Mapping[str, SBMLCompartment] = field(default_factory=OrderedDict) + species: Mapping[str, SBMLSpecies] = field(default_factory=OrderedDict) + parameters: Mapping[str, SBMLParameter] = field(default_factory=OrderedDict) + reactions: Mapping[str, SBMLReaction] = field(default_factory=OrderedDict) + rules: List[SBMLRule] = field(default_factory=list) + function_definitions: Mapping[str, SBMLFunctionDefinition] = field( + default_factory=OrderedDict + ) + events: List[SBMLEvent] = field(default_factory=list) + initial_assignments: List[SBMLInitialAssignment] = field(default_factory=list) + species_by_compartment: Mapping[str, List[str]] = field(default_factory=OrderedDict) + unit_definitions: Mapping[str, Any] = field(default_factory=OrderedDict) + level: Optional[int] = None + version: Optional[int] = None + substance_units: str = "" + time_units: str = "" + volume_units: str = "" + area_units: str = "" + length_units: str = "" + extent_units: str = "" + conversion_factor: Optional[str] = None + constraint_count: int = 0 + multi_molecule_types: List[str] = field(default_factory=list) + multi_complex_patterns: List[str] = field(default_factory=list) + multi_seed_patterns: List[str] = field(default_factory=list) + import_warnings: List[Union[Dict[str, Any], SBMLImportWarning]] = field( + default_factory=list + ) + + def __post_init__(self) -> None: + self.import_warnings = [ + coerce_import_warning(warning) for warning in self.import_warnings + ] + + +@dataclass +class ReactionPattern: + type: str + reactants: List[str] + products: List[str] + modifiers: List[str] = field(default_factory=list) + catalyst: Optional[str] = None + + +@dataclass +class SCTEntry: + structure: Species + components: List[str] + sbml_id: str + is_elemental: bool + modifications: Dict[str, str] = field(default_factory=dict) + weight: int = 0 + bonds: List[Tuple[str, str]] = field(default_factory=list) + + +@dataclass +class SpeciesCompositionTable: + entries: Mapping[str, SCTEntry] = field(default_factory=OrderedDict) + dependencies: Mapping[str, set] = field(default_factory=OrderedDict) + reverse_dependencies: Mapping[str, set] = field(default_factory=OrderedDict) + sorted_species: List[str] = field(default_factory=list) + weights: List[Tuple[str, int]] = field(default_factory=list) + + +@dataclass +class SeedSpeciesEntry: + species: Species + concentration: str + compartment: str + sbml_id: str + + +@dataclass +class AtomizerResult: + bngl: str + database: Any = None + annotation: Any = None + observable_map: Mapping[str, str] = field(default_factory=OrderedDict) + log: List[Any] = field(default_factory=list) + success: bool = True + error: Optional[str] = None + + +@dataclass +class NamingConventions: + """Playground naming-convention configuration with Python-native keys.""" + + modification_list: List[str] = field(default_factory=list) + reaction_site: List[str] = field(default_factory=list) + reaction_state: List[str] = field(default_factory=list) + definitions: List[Dict[str, int]] = field(default_factory=list) + patterns: Mapping[Tuple[str, ...], str] = field(default_factory=OrderedDict) + + +def _alias_property(target: str) -> property: + """Create a writable camel-case view over a snake_case data field.""" + + return property( + lambda instance: getattr(instance, target), + lambda instance, value: setattr(instance, target, value), + ) + + +# Preserve the public field spellings from the TypeScript config contracts +# alongside BNG3's snake_case Python data model. +AnnotationInfo.qualifierType = _alias_property("qualifier_type") +AnnotationInfo.biologicalQualifier = _alias_property("biological_qualifier") +AnnotationInfo.modelQualifier = _alias_property("model_qualifier") + +SBMLCompartment.spatialDimensions = _alias_property("spatial_dimensions") +SBMLCompartment.sizeSet = _alias_property("size_set") + +SBMLSpecies.initialConcentration = _alias_property("initial_concentration") +SBMLSpecies.initialAmount = _alias_property("initial_amount") +SBMLSpecies.substanceUnits = _alias_property("substance_units") +SBMLSpecies.hasOnlySubstanceUnits = _alias_property("has_only_substance_units") +SBMLSpecies.boundaryCondition = _alias_property("boundary_condition") +SBMLSpecies.initialAmountSet = _alias_property("initial_amount_set") +SBMLSpecies.initialConcentrationSet = _alias_property("initial_concentration_set") +SBMLSpecies.sboTerm = _alias_property("sbo_term") +SBMLSpecies.conversionFactor = _alias_property("conversion_factor") +SBMLSpecies.speciesType = _alias_property("species_type") + +SBMLSpeciesReference.stoichiometrySet = _alias_property("stoichiometry_set") +SBMLSpeciesReference.variableStoichiometry = _alias_property("variable_stoichiometry") + +SBMLKineticLaw.mathML = _alias_property("math_ml") +SBMLKineticLaw.localParameters = _alias_property("local_parameters") + +SBMLReaction.kineticLaw = _alias_property("kinetic_law") +SBMLReaction.conversionFactor = _alias_property("conversion_factor") + +SBMLEvent.useValuesFromTriggerTime = _alias_property("use_values_from_trigger_time") +SBMLEvent.triggerInitialValue = _alias_property("trigger_initial_value") +SBMLEvent.triggerPersistent = _alias_property("trigger_persistent") + +SBMLModel.functionDefinitions = _alias_property("function_definitions") +SBMLModel.speciesByCompartment = _alias_property("species_by_compartment") +SBMLModel.substanceUnits = _alias_property("substance_units") +SBMLModel.timeUnits = _alias_property("time_units") +SBMLModel.volumeUnits = _alias_property("volume_units") +SBMLModel.areaUnits = _alias_property("area_units") +SBMLModel.lengthUnits = _alias_property("length_units") +SBMLModel.extentUnits = _alias_property("extent_units") +SBMLModel.conversionFactor = _alias_property("conversion_factor") +SBMLModel.constraintCount = _alias_property("constraint_count") +SBMLModel.multiMoleculeTypes = _alias_property("multi_molecule_types") +SBMLModel.multiComplexPatterns = _alias_property("multi_complex_patterns") +SBMLModel.multiSeedPatterns = _alias_property("multi_seed_patterns") +SBMLModel.importWarnings = _alias_property("import_warnings") + +SCTEntry.sbmlId = _alias_property("sbml_id") +SCTEntry.isElemental = _alias_property("is_elemental") +SpeciesCompositionTable.reverseDependencies = _alias_property("reverse_dependencies") +SpeciesCompositionTable.sortedSpecies = _alias_property("sorted_species") +SeedSpeciesEntry.sbmlId = _alias_property("sbml_id") +AtomizerResult.observableMap = _alias_property("observable_map") +NamingConventions.modificationList = _alias_property("modification_list") +NamingConventions.reactionSite = _alias_property("reaction_site") +NamingConventions.reactionState = _alias_property("reaction_state") + + +DEFAULT_NAMING_PATTERNS = { + ("+ p",): "Phosphorylation", + ("+ P",): "Phosphorylation", + ("+ _", "+ p"): "Phosphorylation", + ("+ _", "+ P"): "Phosphorylation", + ("+ p", "+ h", "+ o", "+ s"): "Phosphorylation", + ("+ p", "+ h", "+ o", "+ s", "+ p", "+ h", "+ o"): "Phosphorylation", + ("+ p", "+ p"): "Double-Phosphorylation", + ("+ P", "+ P"): "Double-Phosphorylation", + ("+ _", "+ P", "+ P"): "Double-Phosphorylation", + ("+ P", "+ P", "+ _"): "Double-Phosphorylation", + ("+ _", "+ p", "+ p"): "Double-Phosphorylation", + ("+ p", "+ p", "+ p"): "Triple-Phosphorylation", + ("+ P", "+ P", "+ P"): "Triple-Phosphorylation", + ("+ u", "+ b"): "Ubiquitination", + ("+ U", "+ b"): "Ubiquitination", + ("+ _", "+ u", "+ b"): "Ubiquitination", + ("+ _", "+ U", "+ b"): "Ubiquitination", + ("+ u", "+ b", "+ i", "+ q"): "Ubiquitination", + ("+ a", "+ c"): "Acetylation", + ("+ A", "+ c"): "Acetylation", + ("+ _", "+ a", "+ c"): "Acetylation", + ("+ a", "+ c", "+ e", "+ t"): "Acetylation", + ("+ m", "+ e"): "Methylation", + ("+ M", "+ e"): "Methylation", + ("+ _", "+ m", "+ e"): "Methylation", + ("+ m", "+ e", "+ t", "+ h"): "Methylation", + ("+ a",): "Activation", + ("+ A",): "Activation", + ("+ *",): "Activation", + ("+ _", "+ a"): "Activation", + ("+ _", "+ A"): "Activation", + ("+ _", "+ a", "+ c", "+ t"): "Activation", + ("+ a", "+ c", "+ t", "+ i", "+ v", "+ e"): "Activation", + ("+ i",): "Inactivation", + ("+ I",): "Inactivation", + ("+ _", "+ i"): "Inactivation", + ("+ _", "+ I"): "Inactivation", + ("+ i", "+ n", "+ a", "+ c", "+ t"): "Inactivation", + ("+ _",): "Binding", + ("+ _", "+ b", "+ o", "+ u", "+ n", "+ d"): "Binding", + ("+ _", "+ c", "+ y", "+ t"): "Localization", + ("+ _", "+ n", "+ u", "+ c"): "Localization", + ("+ _", "+ m", "+ e", "+ m"): "Localization", + ("+ _", "+ p", "+ m"): "Localization", + ("+ _", "+ e", "+ r"): "Localization", + ("+ 2",): "Dimerization", + ("+ _", "+ d", "+ i", "+ m"): "Dimerization", + ("+ _", "+ d", "+ i", "+ m", "+ e", "+ r"): "Dimerization", + ("+ 3",): "Trimerization", + ("+ _", "+ t", "+ r", "+ i", "+ m"): "Trimerization", +} + + +DEFAULT_NAMING_CONVENTIONS = NamingConventions( + modification_list=[ + "Phosphorylation", + "Double-Phosphorylation", + "Triple-Phosphorylation", + "Dephosphorylation", + "Ubiquitination", + "Deubiquitination", + "Acetylation", + "Deacetylation", + "Methylation", + "Demethylation", + "Activation", + "Inactivation", + "Binding", + "Unbinding", + "Localization", + "Translocation", + "Dimerization", + "Trimerization", + "Oligomerization", + "Cleavage", + "Synthesis", + "Degradation", + ], + reaction_site=[ + "phospho", + "phos", + "p", + "ub", + "ubiq", + "ac", + "acet", + "me", + "methyl", + "act", + "active", + "inact", + "inactive", + ], + reaction_state=[ + "P", + "PP", + "PPP", + "U", + "UU", + "Ac", + "Me", + "MeMe", + "A", + "I", + "0", + "1", + ], + definitions=[ + {"rsi": 0, "rst": 0}, + {"rsi": 0, "rst": 1}, + {"rsi": 1, "rst": 2}, + ], + patterns=DEFAULT_NAMING_PATTERNS, +) + + +_SBML_TO_BNGL_TRANSLATION = { + "^": "", + "'": "", + "*": "m", + " ": "_", + "#": "sh", + ":": "_", + "\u03b1": "a", + "\u03b2": "b", + "\u03b3": "g", + "\u03b4": "d", + "\u03b5": "e", + "\u03b6": "z", + "\u03b7": "h", + "\u03b8": "th", + "\u03b9": "i", + "\u03ba": "k", + "\u03bb": "l", + "\u03bc": "u", + "\u03bd": "n", + "\u03be": "x", + "\u03bf": "o", + "\u03c0": "pi", + "\u03c1": "r", + "\u03c2": "s", + "\u03c3": "s", + "\u03c4": "t", + "\u03c5": "u", + "\u03c6": "ph", + "\u03c7": "ch", + "\u03c8": "ps", + "\u03c9": "o", + "\u0391": "A", + "\u0392": "B", + "\u0393": "G", + "\u0394": "D", + "\u0395": "E", + "+": "pl", + "/": "_", + "-": "_", + ".": "_", + "?": "unkn", + ",": "_", + "(": "", + ")": "", + "[": "", + "]": "", + ">": "_", + "<": "_", + "&": "and", + "|": "or", + "=": "eq", + "%": "pct", + "@": "at", + "!": "", + "~": "", + "`": "", + '"': "", + "\\": "_", +} + +# Keep this synchronized with the generated BNGL lexer. Exact keyword +# collisions are lexical errors, even when the spelling is otherwise a valid +# identifier (for example, an SBML species named ``time``). +BNGL_LEXER_KEYWORDS = frozenset( + { + "_e", + "_pi", + "abs", + "acos", + "acosh", + "actions", + "addConcentration", + "argfile", + "Arrhenius", + "asin", + "asinh", + "atan", + "atanh", + "atol", + "atomize", + "avg", + "background", + "bdf", + "begin", + "bifurcate", + "binary_output", + "blocks", + "check_iso", + "collapse", + "compartments", + "complex", + "continue", + "cos", + "cosh", + "Counter", + "DeleteMolecules", + "end", + "energy", + "equil", + "evaluate_expressions", + "exclude_products", + "exclude_reactants", + "execute", + "exp", + "false", + "file", + "format", + "FunctionProduct", + "functions", + "generate_hybrid_model", + "get_final_state", + "gml", + "groups", + "Hill", + "if", + "include_model", + "include_network", + "include_products", + "include_reactants", + "ln", + "log_scale", + "log10", + "log2", + "max", + "max_agg", + "max_conv_fails", + "max_err_test_fails", + "max_iter", + "max_num_steps", + "max_sim_steps", + "max_step", + "max_stoich", + "maxOrder", + "method", + "min", + "MM", + "model", + "molecular", + "molecule_types", + "molecules", + "MoveConnected", + "mratio", + "n_output_steps", + "n_scan_pts", + "netfile", + "nf", + "nocslf", + "notf", + "observables", + "ode", + "opts", + "output_step_interval", + "overwrite", + "par_max", + "par_min", + "param", + "parameter", + "parameter_scan", + "parameters", + "patterns", + "pla", + "pla_config", + "pla_output", + "population", + "prefix", + "pretty_formatting", + "print_CDAT", + "print_end", + "print_functions", + "print_iter", + "print_net", + "print_on_stop", + "priority", + "quit", + "reaction", + "reaction_rules", + "readFile", + "reset_conc", + "resetConcentrations", + "resetParameters", + "rint", + "rtol", + "rules", + "safe", + "sample_times", + "Sat", + "save_progress", + "saveConcentrations", + "saveParameters", + "seed", + "setConcentration", + "setModelName", + "setOption", + "setParameter", + "setVolume", + "simulate", + "simulate_nf", + "simulate_ode", + "simulate_pla", + "simulate_psa", + "simulate_rm", + "sin", + "sinh", + "skip_actions", + "sparse", + "species", + "sqrt", + "ssa", + "stats", + "steady_state", + "stiff", + "stop_if", + "substanceUnits", + "suffix", + "sum", + "t_end", + "t_start", + "tan", + "tanh", + "TextReaction", + "TextSpecies", + "TFUN", + "time", + "TotalRate", + "true", + "type", + "types", + "utl", + "verbose", + "version", + "visualize", + "writeFile", + "writeLatex", + "writeMDL", + "writeMexfile", + "writeMfile", + "writeModel", + "writeNetwork", + "writeSBML", + "writeSSC", + "writeSSCcfg", + "writeXML", + } +) + + +def standardize_name(value: str) -> str: + """Return a BNGL-safe identifier, matching Playground normalization.""" + + import re + + result = str(value or "") + for character, replacement in _SBML_TO_BNGL_TRANSLATION.items(): + result = result.replace(character, replacement) + result = re.sub(r"[^A-Za-z0-9_]", "_", result) + if not result: + result = "unnamed" + if result[0].isdigit(): + result = "_" + result + if result in BNGL_LEXER_KEYWORDS: + result += "_id" + return result + + +def get_kinetic_math(kinetic_law: Any) -> str: + if kinetic_law is None: + return "" + if isinstance(kinetic_law, Mapping): + return str(kinetic_law.get("math", "")) + return str(getattr(kinetic_law, "math", "")) + + +__all__ = [ + "AnnotationInfo", + "AtomizerResult", + "BNGL_LEXER_KEYWORDS", + "NamingConventions", + "ReactionPattern", + "SBMLEvent", + "SBMLEventAssignment", + "SBMLCompartment", + "SBMLFunctionDefinition", + "SBMLInitialAssignment", + "SBMLImportWarning", + "SBMLKineticLaw", + "SBMLModel", + "SBMLModifierSpeciesReference", + "SBMLParameter", + "SBMLReaction", + "SBMLRule", + "SBMLSpecies", + "SBMLSpeciesReference", + "SCTEntry", + "SeedSpeciesEntry", + "SpeciesCompositionTable", + "DEFAULT_NAMING_CONVENTIONS", + "DEFAULT_NAMING_PATTERNS", + "get_kinetic_math", + "standardize_name", +] diff --git a/python/bionetgen/atomizer/modern/uniprot.py b/python/bionetgen/atomizer/modern/uniprot.py new file mode 100644 index 00000000..f727316e --- /dev/null +++ b/python/bionetgen/atomizer/modern/uniprot.py @@ -0,0 +1,163 @@ +"""Small, cached UniProt REST client used by the modern Atomizer.""" + +from __future__ import annotations + +import json +from collections import OrderedDict +from dataclasses import dataclass, field +from typing import Any, List, Mapping, Optional, Tuple +from urllib.parse import quote +from urllib.request import Request, urlopen + + +@dataclass(frozen=True) +class UniProtEntry: + """Subset of UniProtKB metadata consumed by Atomizer callers.""" + + accession: str + name: Optional[str] = None + protein_name: Optional[str] = None + gene_name: Optional[str] = None + organism: Optional[str] = None + function: Optional[str] = None + subcellular_location: List[str] = field(default_factory=list) + keywords: List[str] = field(default_factory=list) + + @property + def proteinName(self) -> Optional[str]: + return self.protein_name + + @property + def geneName(self) -> Optional[str]: + return self.gene_name + + @property + def subcellularLocation(self) -> List[str]: + return self.subcellular_location + + +_CACHE_LIMIT = 200 +_cache: "OrderedDict[str, Optional[UniProtEntry]]" = OrderedDict() + + +def _cache_set(key: str, value: Optional[UniProtEntry]) -> None: + if key in _cache: + del _cache[key] + _cache[key] = value + while len(_cache) > _CACHE_LIMIT: + _cache.popitem(last=False) + + +def _request_json(url: str, timeout: float) -> Tuple[bool, Optional[Mapping[str, Any]]]: + request = Request(url, headers={"Accept": "application/json"}) + with urlopen(request, timeout=timeout) as response: + status = getattr(response, "status", None) + if status is None: + status = response.getcode() + if not 200 <= status < 300: + return False, None + data = json.loads(response.read().decode("utf-8")) + return True, data if isinstance(data, Mapping) else None + + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _list(value: Any) -> list[Any]: + return value if isinstance(value, list) else [] + + +def _value(value: Any) -> Optional[str]: + if isinstance(value, Mapping): + value = value.get("value") + return str(value) if value is not None and value != "" else None + + +def _parse_entry(key: str, data: Mapping[str, Any]) -> UniProtEntry: + protein_description = _mapping(data.get("proteinDescription")) + recommended_name = _mapping(protein_description.get("recommendedName")) + full_name = _value(_mapping(recommended_name.get("fullName"))) + + genes = _list(data.get("genes")) + first_gene = _mapping(genes[0]) if genes else {} + gene_name = _value(_mapping(first_gene.get("geneName"))) + if gene_name is None: + synonyms = _list(first_gene.get("synonyms")) + gene_name = _value(synonyms[0]) if synonyms else None + + comments = [_mapping(comment) for comment in _list(data.get("comments"))] + function = None + locations: List[str] = [] + for comment in comments: + comment_type = comment.get("type") + if comment_type == "FUNCTION" and function is None: + texts = _list(comment.get("texts")) + function = _value(texts[0]) if texts else None + if comment_type != "SUBCELLULAR_LOCATION": + continue + for location in _list(comment.get("subcellularLocations")): + location_value = _value(_mapping(location).get("location")) + if location_value: + locations.append(location_value) + + keywords = [ + value + for keyword in _list(data.get("keywords")) + if (value := _value(keyword)) is not None + ] + primary_accession = _value(data.get("primaryAccession")) + name = primary_accession or _value(data.get("accession")) or key + organism = _value(_mapping(data.get("organism")).get("scientificName")) + return UniProtEntry( + accession=key, + name=name, + protein_name=full_name, + gene_name=gene_name, + organism=organism, + function=function, + subcellular_location=locations, + keywords=keywords, + ) + + +def fetch_uniprot_entry( + accession: str, timeout: float = 20.0 +) -> Optional[UniProtEntry]: + """Fetch one UniProtKB entry, returning ``None`` on network/API failure.""" + + key = str(accession).upper() + if key in _cache: + return _cache[key] + + url = f"https://rest.uniprot.org/uniprotkb/{quote(key, safe='')}.json" + try: + ok, data = _request_json(url, timeout) + if not ok or data is None: + _cache_set(key, None) + return None + entry = _parse_entry(key, data) + _cache_set(key, entry) + return entry + except Exception: + _cache_set(key, None) + return None + + +def clear_uniprot_cache() -> None: + """Clear cached successful and failed lookups.""" + + _cache.clear() + + +fetchUniProtEntry = fetch_uniprot_entry +clearUniProtCache = clear_uniprot_cache + + +__all__ = [ + "UniProtEntry", + "clearUniProtCache", + "clear_uniprot_cache", + "fetchUniProtEntry", + "fetch_uniprot_entry", +] diff --git a/python/bionetgen/atomizer/modern/units.py b/python/bionetgen/atomizer/modern/units.py new file mode 100644 index 00000000..a47b281d --- /dev/null +++ b/python/bionetgen/atomizer/modern/units.py @@ -0,0 +1,204 @@ +"""SBML unit normalization for the Playground-derived atomizer. + +The Playground parser converts declared SBML quantities to SI-base values before +the BNGL writer turns concentrations into molecule counts. Keeping that step +separate makes the transformation auditable and leaves unit-less BNG round trips +unchanged. +""" + +from __future__ import annotations + +import math +from typing import Any, Dict, Iterable, Mapping + +from .types import SBMLModel + + +def _term_value(term: Any, key: str, index: int, default: float) -> float: + if isinstance(term, Mapping): + value = term.get(key, default) + else: + try: + value = term[index] + except (IndexError, KeyError, TypeError): + value = default + try: + result = float(value) + except (TypeError, ValueError): + return default + return result if math.isfinite(result) else default + + +def unit_conversion_factor(terms: Iterable[Any]) -> float: + """Return ``Π (multiplier * 10**scale)**exponent`` for one unit definition.""" + + factor = 1.0 + for term in terms: + scale = _term_value(term, "scale", 1, 0.0) + exponent = _term_value(term, "exponent", 2, 1.0) + multiplier = _term_value(term, "multiplier", 3, 1.0) + if multiplier < 0: + return 1.0 + try: + factor *= math.pow(multiplier * math.pow(10.0, scale), exponent) + except (OverflowError, ValueError, ZeroDivisionError): + return 1.0 + if not math.isfinite(factor): + return 1.0 + return factor + + +def resolve_unit_factor(unit_id: str, model: SBMLModel) -> float: + """Resolve a declared unit id, treating unknown/base units as a no-op.""" + + if not unit_id: + return 1.0 + terms = model.unit_definitions.get(unit_id) + if terms is None: + return 1.0 + factor = unit_conversion_factor(terms) + return factor if math.isfinite(factor) and factor != 0 else 1.0 + + +def _near_one(factor: float) -> bool: + return abs(factor - 1.0) < 1e-12 + + +def _warning(message: str) -> Dict[str, Any]: + return {"category": "units", "message": message, "count": 1, "severity": "info"} + + +def _local_parameters(kinetic_law: Any): + if kinetic_law is None: + return [] + if isinstance(kinetic_law, Mapping): + return kinetic_law.get("localParameters", []) or [] + return getattr(kinetic_law, "local_parameters", []) or [] + + +def _parameter_value(parameter: Any) -> float: + value = ( + parameter.get("value") + if isinstance(parameter, Mapping) + else getattr(parameter, "value", 0) + ) + try: + value = float(value) + except (TypeError, ValueError): + return 0.0 + return value if math.isfinite(value) else 0.0 + + +def _set_parameter_value(parameter: Any, value: float) -> None: + if isinstance(parameter, Mapping): + parameter["value"] = value + else: + parameter.value = value + + +def apply_unit_scaling(model: SBMLModel) -> list[Dict[str, Any]]: + """Scale parsed SBML values into SI-base units and return audit warnings.""" + + warnings: list[Dict[str, Any]] = [] + substance_default = getattr(model, "substance_units", "") + volume_default = getattr(model, "volume_units", "") + area_default = getattr(model, "area_units", "") + length_default = getattr(model, "length_units", "") + + def scale_parameter(parameter: Any, scope: str) -> None: + parameter_id = ( + parameter.get("id", "") + if isinstance(parameter, Mapping) + else getattr(parameter, "id", "") + ) + units = ( + parameter.get("units", "") + if isinstance(parameter, Mapping) + else getattr(parameter, "units", "") + ) + factor = resolve_unit_factor(str(units or ""), model) + value = _parameter_value(parameter) + if not _near_one(factor) and math.isfinite(value): + _set_parameter_value(parameter, value * factor) + warnings.append( + _warning( + f'Scaled {scope} parameter "{parameter_id}" by {factor:g} ' + f'(unit "{units}") to SI base units.' + ) + ) + + for parameter in model.parameters.values(): + scale_parameter(parameter, "global") + for reaction in model.reactions.values(): + for parameter in _local_parameters(getattr(reaction, "kinetic_law", None)): + scale_parameter(parameter, f"local ({getattr(reaction, 'id', 'reaction')})") + + compartment_factors: Dict[str, float] = {} + for compartment_id, compartment in model.compartments.items(): + dimensions = float(getattr(compartment, "spatial_dimensions", 3) or 3) + default_units = ( + area_default + if dimensions == 2 + else length_default if dimensions == 1 else volume_default + ) + units = str(getattr(compartment, "units", "") or default_units or "") + factor = resolve_unit_factor(units, model) + compartment_factors[compartment_id] = factor + size = float(getattr(compartment, "size", 0) or 0) + if not _near_one(factor) and math.isfinite(size): + compartment.size = size * factor + warnings.append( + _warning( + f'Scaled compartment "{compartment_id}" size by {factor:g} ' + f'(unit "{units}").' + ) + ) + + for species_id, species in model.species.items(): + substance_units = str( + getattr(species, "substance_units", "") or substance_default or "" + ) + substance_factor = resolve_unit_factor(substance_units, model) + volume_factor = compartment_factors.get( + getattr(species, "compartment", ""), 1.0 + ) + amount = float(getattr(species, "initial_amount", 0) or 0) + amount_set = bool(getattr(species, "initial_amount_set", False) or amount != 0) + if amount_set and not _near_one(substance_factor) and math.isfinite(amount): + species.initial_amount = amount * substance_factor + warnings.append( + _warning( + f'Scaled species "{species_id}" initialAmount by ' + f"{substance_factor:g}." + ) + ) + + concentration_factor = substance_factor / (volume_factor or 1.0) + concentration = float(getattr(species, "initial_concentration", 0) or 0) + concentration_set = bool( + getattr(species, "initial_concentration_set", False) or concentration != 0 + ) + if ( + concentration_set + and not _near_one(concentration_factor) + and math.isfinite(concentration) + ): + species.initial_concentration = concentration * concentration_factor + warnings.append( + _warning( + f'Scaled species "{species_id}" initialConcentration by ' + f"{concentration_factor:g}." + ) + ) + + if warnings: + warnings.append( + _warning( + f"Applied SBML unit conversion to {len(warnings)} quantity/ies. " + "Check these factors if a model declares inconsistent units." + ) + ) + return warnings + + +__all__ = ["apply_unit_scaling", "resolve_unit_factor", "unit_conversion_factor"] diff --git a/python/bionetgen/atomizer/modern/writer.py b/python/bionetgen/atomizer/modern/writer.py new file mode 100644 index 00000000..71cc9f90 --- /dev/null +++ b/python/bionetgen/atomizer/modern/writer.py @@ -0,0 +1,2598 @@ +"""BNGL writer for the Playground atomizer port.""" + +from __future__ import annotations + +import math +import os +import re +from collections import OrderedDict +from dataclasses import dataclass, field +from typing import ( + Dict, + Iterable, + Iterator, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, +) + +from .events import EventTranslationContext, synthesize_event_actions +from .rate_rule_constants import ( + RATE_RULE_META_PREFIX, + RATE_RULE_NEG_PREFIX, + RATE_RULE_POS_PREFIX, + SYNTH_RATE_RULE_SPECIES_PREFIX, +) +from .structures import Molecule, Species +from .helpers import logger +from .types import ( + BNGL_LEXER_KEYWORDS, + SBMLModel, + SBMLReaction, + SCTEntry, + SeedSpeciesEntry, + SpeciesCompositionTable, + coerce_import_warning, + get_kinetic_math, + standardize_name, +) + +_PROTECTED_BUILTIN_OPERANDS = frozenset({"time", "_pi", "_e", "true", "false"}) + +_MISSING_KINETIC_RATE_FALLBACK = ( + os.environ.get("BNGL_MISSING_KINETIC_RATE", "1").strip() or "1" +) +try: + _MISSING_KINETIC_LOG_LIMIT = int( + os.environ.get("BNGL_MISSING_KINETIC_LOG_LIMIT", "25") + ) +except ValueError: + _MISSING_KINETIC_LOG_LIMIT = 25 +_missing_kinetic_log_count = 0 + +try: + _TRANSPORT_LOG_LIMIT = int(os.environ.get("BNGL_TRANSPORT_LOG_LIMIT", "40")) +except ValueError: + _TRANSPORT_LOG_LIMIT = 40 +_transport_log_count = 0 + + +@dataclass +class BNGLGenerationResult: + """Named result returned by the Playground BNGL generation facade.""" + + bngl: str + observable_map: Mapping[str, str] + warnings: List[str] = field(default_factory=list) + + @property + def observableMap(self) -> Mapping[str, str]: + """Expose the TypeScript spelling without copying the map.""" + + return self.observable_map + + def __iter__(self) -> Iterator[object]: + """Keep the historical Python ``bngl, observable_map = ...`` contract.""" + + yield self.bngl + yield self.observable_map + + def __len__(self) -> int: + return 2 + + def __getitem__(self, index: int) -> object: + if index == 0: + return self.bngl + if index == 1: + return self.observable_map + raise IndexError(index) + + +def _log_missing_kinetic(message: str) -> None: + global _missing_kinetic_log_count + + if ( + _MISSING_KINETIC_LOG_LIMIT < 0 + or _missing_kinetic_log_count < _MISSING_KINETIC_LOG_LIMIT + ): + logger.warning("BNW011", message) + elif _missing_kinetic_log_count == _MISSING_KINETIC_LOG_LIMIT: + logger.warning("BNW011", "Additional missing-kinetic-law logs suppressed.") + _missing_kinetic_log_count += 1 + + +def _log_transport_info(message: str) -> None: + global _transport_log_count + + if _TRANSPORT_LOG_LIMIT < 0 or _transport_log_count < _TRANSPORT_LOG_LIMIT: + logger.info("BNW004", message) + elif _transport_log_count == _TRANSPORT_LOG_LIMIT: + logger.info("BNW004", "Additional transport reaction logs suppressed.") + _transport_log_count += 1 + + +def _section(name: str, lines: Iterable[str]) -> str: + content = list(lines) + if not content: + return "" + return ( + "begin " + + name + + "\n" + + "\n".join(f" {line}" for line in content) + + "\nend " + + name + ) + + +def _print_reaction_species( + chemical: Tuple[str, float, str], + tag: str, + translator: Mapping[str, Species], +) -> str: + """Render one Playground ``bnglReaction`` species entry.""" + + species, stoichiometry, _compartment = chemical + if species not in translator: + rendered = f"{species}{tag}" + else: + pattern = translator[species] + pattern.add_compartment(tag) + pattern.renumber_bonds() + rendered = str(pattern) + + if float(stoichiometry).is_integer(): + return " + ".join(rendered for _ in range(int(stoichiometry))) + + logger.error("BNW002", f"Non-integer stoichiometry: {stoichiometry} * {species}") + return rendered + + +def bngl_reaction( + reactants: Sequence[Tuple[str, float, str]], + products: Sequence[Tuple[str, float, str]], + rate: str, + tags: Mapping[str, str], + translator: Optional[Mapping[str, Species]] = None, + is_compartments: bool = False, + reversible: bool = True, + comment: str = "", + reaction_name: Optional[str] = None, +) -> str: + """Render one reaction using the Playground writer's public contract.""" + + translator = translator or {} + if not reactants or (len(reactants) == 1 and reactants[0][1] == 0): + result = "0 " + else: + rendered = [] + for species, stoichiometry, compartment in reactants: + tag = tags.get(compartment, "") if is_compartments else "" + rendered.append( + _print_reaction_species( + (species, stoichiometry, compartment), tag, translator + ) + ) + result = " + ".join(rendered) + + result += " <-> " if reversible else " -> " + + if not products: + result += "0 " + else: + rendered = [] + for species, stoichiometry, compartment in products: + tag = tags.get(compartment, "") if is_compartments else "" + rendered.append( + _print_reaction_species( + (species, stoichiometry, compartment), tag, translator + ) + ) + result += " + ".join(rendered) + + result += f" {rate}" + if comment: + result += f" {comment}" + result = re.sub(r"(^|\s)0\(\)(?=\s|$)", r"\1 0", result) + if reaction_name: + result = f"{reaction_name}: {result}" + return result + + +def _molecule_pattern(molecule: Molecule) -> str: + value = molecule.to_string(True).replace("-", "_") + if "(" not in value: + value = value + "()" + return value + + +def _pattern(species: Species, compartment: str = "") -> str: + species.renumber_bonds() + molecules = [] + for molecule in species.molecules: + value = _molecule_pattern(molecule) + value = re.sub(r"^([A-Za-z_][A-Za-z0-9_]*)", r"M_\1", value) + if "@" not in value and compartment: + value += "@" + standardize_name(compartment) + molecules.append(value) + return ".".join(sorted(molecules)) + + +def _number(value: object) -> str: + try: + number = float(value) + except (TypeError, ValueError): + return str(value) + if number.is_integer(): + return str(int(number)) + return format(number, ".15g") + + +def _evaluate_arithmetic(expression: str) -> Optional[float]: + """Evaluate only the arithmetic grammar accepted for constant seed folding.""" + + tokens = re.findall( + r"(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?|[+\-*/^()]", + expression, + ) + if not tokens or "".join(tokens) != re.sub(r"\s+", "", expression): + return None + + position = 0 + + def peek() -> Optional[str]: + return tokens[position] if position < len(tokens) else None + + def precedence(operator: str) -> int: + return {"+": 1, "-": 1, "*": 2, "/": 2, "^": 3}.get(operator, 0) + + def parse_expression(min_precedence: int) -> float: + nonlocal position + token = peek() + if token is None: + raise ValueError("missing operand") + if token == "(": + position += 1 + value = parse_expression(0) + if peek() != ")": + raise ValueError("unbalanced parentheses") + position += 1 + elif token in {"+", "-"}: + position += 1 + operand = parse_expression(3) + value = operand if token == "+" else -operand + else: + try: + value = float(token) + except (TypeError, ValueError) as error: + raise ValueError("invalid number") from error + position += 1 + + while position < len(tokens): + operator = peek() + if operator == ")": + break + if operator is None: + break + current_precedence = precedence(operator) + if current_precedence == 0 or current_precedence < min_precedence: + break + position += 1 + right = parse_expression( + current_precedence if operator == "^" else current_precedence + 1 + ) + if operator == "+": + value += right + elif operator == "-": + value -= right + elif operator == "*": + value *= right + elif operator == "/": + value /= right + else: + value = value**right + return value + + try: + value = parse_expression(0) + if position != len(tokens) or not math.isfinite(value): + return None + return value + except (ArithmeticError, ValueError, OverflowError): + return None + + +def _numeric_value(value: object) -> Optional[float]: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) else None + + +def _expression_identifiers(expression: str) -> List[str]: + return re.findall(r"[A-Za-z_][A-Za-z0-9_]*", expression or "") + + +def convert_math_expression(expression: str) -> str: + """Convert common MathML/libSBML function spellings to BNGL syntax.""" + + result = str(expression or "").strip() + result = _replace_nested_function( + result, + "pow", + lambda args: ( + f"(({args[0]})^({args[1]}))" + if len(args) >= 2 + else f"pow({', '.join(args)})" + ), + ) + result = _replace_nested_function( + result, + "power", + lambda args: ( + f"(({args[0]})^({args[1]}))" + if len(args) >= 2 + else f"power({', '.join(args)})" + ), + ) + result = _replace_nested_function( + result, "sqrt", lambda args: f"(({args[0]})^(1/2))" if args else "sqrt()" + ) + result = _replace_nested_function( + result, "sqr", lambda args: f"(({args[0]})^2)" if args else "sqr()" + ) + result = _replace_nested_function( + result, + "exp", + lambda args: f"(2.71828182845905^({args[0]}))" if args else "exp()", + ) + result = _replace_nested_function( + result, + "abs", + lambda args: f"if({args[0]}>=0,{args[0]},-({args[0]}))" if args else "abs()", + ) + + # SBML log(base, value) is distinct from the one-argument natural log. + result = _replace_nested_function( + result, + "log", + lambda args: ( + f"(ln({args[1]})/ln({args[0]}))" + if len(args) >= 2 + else f"ln({args[0]})" if args else "log()" + ), + ) + result = _replace_nested_function( + result, + "log10", + lambda args: f"(ln({args[0]})/2.302585093)" if args else "log10()", + ) + result = _replace_nested_function( + result, + "root", + lambda args: ( + f"(({args[1]})^(1/({args[0]})))" + if len(args) >= 2 + else f"(({args[0]})^(1/2))" if args else "root()" + ), + ) + result = _replace_nested_function( + result, + "floor", + lambda args: ( + f"(rint({args[0]}) - if(rint({args[0]}) > ({args[0]}), 1, 0))" + if args + else "floor()" + ), + ) + result = _replace_nested_function( + result, + "ceiling", + lambda args: ( + f"(rint({args[0]}) + if(rint({args[0]}) < ({args[0]}), 1, 0))" + if args + else "ceiling()" + ), + ) + result = _replace_nested_function( + result, + "ceil", + lambda args: ( + f"(rint({args[0]}) + if(rint({args[0]}) < ({args[0]}), 1, 0))" + if args + else "ceil()" + ), + ) + for function, operator in { + "gt": ">", + "lt": "<", + "geq": ">=", + "leq": "<=", + "eq": "==", + "neq": "!=", + "and": "&&", + "or": "||", + }.items(): + result = _replace_nested_function( + result, + function, + lambda args, operator=operator, function=function: ( + f"({args[0]} {operator} {args[1]})" + if len(args) >= 2 + else f"{function}({', '.join(args)})" + ), + ) + result = _replace_nested_function( + result, + "not", + lambda args: f"(!{args[0]})" if len(args) == 1 else f"not({', '.join(args)})", + ) + result = _replace_nested_function(result, "piecewise", _piecewise_expression) + result = _replace_nested_function( + result, + "Sat", + lambda args: ( + f"(({args[0]}) * Sat({args[2]}, {args[1]}))" + if len(args) == 3 + else f"Sat({', '.join(args)})" + ), + ) + result = _replace_nested_function( + result, + "MM", + lambda args: ( + f"(({args[0]}) * MM({args[2]}, {args[1]}))" + if len(args) == 3 + else f"MM({', '.join(args)})" + ), + ) + result = _replace_nested_function( + result, + "Hill", + lambda args: ( + f"(({args[0]}) * ({args[2]})^({args[3]}) / (({args[1]})^({args[3]}) + ({args[2]})^({args[3]})))" + if len(args) == 4 + else f"Hill({', '.join(args)})" + ), + ) + + result = re.sub(r"\bpi\b", "3.14159265358979", result) + result = re.sub( + r"\bexponentiale\b", "2.71828182845905", result, flags=re.IGNORECASE + ) + result = re.sub(r"\btrue\b", "1", result, flags=re.IGNORECASE) + result = re.sub(r"\bfalse\b", "0", result, flags=re.IGNORECASE) + result = re.sub(r"\btime\b(?!\s*\()", "time()", result) + result = result.replace("--", "+") + return result + + +def _split_arguments(inner: str) -> List[str]: + arguments: List[str] = [] + current: List[str] = [] + depth = 0 + for char in inner: + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if char == "," and depth == 0: + arguments.append("".join(current).strip()) + current = [] + else: + current.append(char) + arguments.append("".join(current).strip()) + return arguments + + +def _replace_nested_function(expression: str, function: str, replacer) -> str: + result = expression + pattern = re.compile(rf"\b{re.escape(function)}\s*\(") + search_index = 0 + guard = 0 + while guard < 10000: + match = pattern.search(result, search_index) + if match is None: + break + depth = 1 + close = match.end() + while close < len(result) and depth: + if result[close] == "(": + depth += 1 + elif result[close] == ")": + depth -= 1 + close += 1 + if depth: + break + inner = result[match.end() : close - 1] + replacement = replacer(_split_arguments(inner)) + original = result[match.start() : close] + if replacement == original: + search_index = close + else: + result = result[: match.start()] + replacement + result[close:] + search_index = match.start() + guard += 1 + return result + + +def _piecewise_expression(args: List[str]) -> str: + if len(args) == 1: + return args[0] + if len(args) == 2: + return f"if({args[1]}, {args[0]}, 0)" + otherwise = args[-1] if len(args) % 2 else "0" + start = len(args) - 3 if len(args) % 2 else len(args) - 2 + result = otherwise + for index in range(start, -1, -2): + result = f"if({args[index + 1]}, {args[index]}, {result})" + return result + + +def extend_function( + function_string: str, + parameter_dict: Mapping[str, object], + function_definitions: Mapping[str, object], +) -> str: + """Inline SBML function definitions and scalar parameters at call sites.""" + + result = str(function_string or "") + for function_id, definition in function_definitions.items(): + name = getattr(definition, "name", "") or function_id + arguments = list(getattr(definition, "arguments", []) or []) + body = str(getattr(definition, "math", "") or "") + + def replace_call( + args: List[str], + arguments=arguments, + body=body, + call_name=name, + ): + if not arguments and len(args) == 1 and not args[0]: + return f"({body})" + if len(args) == 1 and not args[0] and arguments: + return f"{call_name}()" + if len(args) != len(arguments): + return f"{call_name}({', '.join(args)})" + expanded = body + for formal, actual in zip(arguments, args): + expanded = re.sub(rf"\b{re.escape(formal)}\b", f"({actual})", expanded) + return f"({expanded})" + + function_names = [name] + if function_id != name: + function_names.append(function_id) + for function_name in function_names: + result = _replace_nested_function( + result, + function_name, + lambda args, call_name=function_name: replace_call( + args, call_name=call_name + ), + ) + + for parameter, value in parameter_dict.items(): + replacement = _number(value) + result = re.sub(rf"\b{re.escape(parameter)}\b", replacement, result) + return result + + +def _definition_field(definition: object, field: str, default: object) -> object: + value = getattr(definition, field, None) + if value is None and isinstance(definition, Mapping): + value = definition.get(field, default) + return default if value is None else value + + +def _expand_function_call( + expression: str, function_name: str, definition: object +) -> str: + """Expand every call of one SBML function with parenthesis-aware parsing.""" + + result = expression + call_pattern = re.compile(rf"\b{re.escape(function_name)}\s*\(") + guard = 0 + while guard < 10000: + match = call_pattern.search(result) + if match is None: + break + depth = 1 + index = match.end() + while depth and index < len(result): + if result[index] == "(": + depth += 1 + elif result[index] == ")": + depth -= 1 + index += 1 + if depth: + break + + actual_arguments = _split_arguments(result[match.end() : index - 1]) + body = str(_definition_field(definition, "math", "") or "") + formal_arguments = list(_definition_field(definition, "arguments", []) or []) + argument_map = { + str(formal).strip(): actual_arguments[position].strip() + for position, formal in enumerate(formal_arguments) + if position < len(actual_arguments) and str(formal).strip() + } + names = sorted(argument_map, key=len, reverse=True) + if names: + formal_pattern = re.compile( + rf"\b(?:{'|'.join(re.escape(name) for name in names)})\b" + ) + body = formal_pattern.sub( + lambda item: f"({argument_map[item.group(0)]})", body + ) + result = result[: match.start()] + f"({body})" + result[index:] + guard += 1 + return result + + +def inline_sbml_functions( + rate_expression: str, function_definitions: Mapping[str, object] +) -> str: + """Inline SBML function calls using the Playground's bounded expansion loop.""" + + result = str(rate_expression or "") + modified = True + iterations = 0 + while modified and iterations < 20: + modified = False + iterations += 1 + for function_id, definition in function_definitions.items(): + function_name = str(function_id) + if not re.search(rf"\b{re.escape(function_name)}\s*\(", result): + continue + expanded = _expand_function_call(result, function_name, definition) + if expanded != result: + result = expanded + modified = True + break + return result + + +def bngl_function( + rule: str, + function_title: str = "", + reactants: Optional[Sequence[str]] = None, + compartments: Optional[Sequence[str]] = None, + parameter_dict: Optional[Mapping[str, object]] = None, + reaction_dict: Optional[Mapping[str, str]] = None, + assignment_rule_variables: Optional[Set[str]] = None, + observable_ids: Optional[Set[str]] = None, + species_to_has_only_substance_units: Optional[Mapping[str, bool]] = None, + observable_converted_rules: Optional[Set[str]] = None, + species_with_conc_functions: Optional[Set[str]] = None, + sbml_to_bngl_id: Optional[Mapping[str, str]] = None, +) -> str: + """Translate a rate/function expression using the Playground contract.""" + + del function_title, observable_ids, species_to_has_only_substance_units + result = str(rule or "") + reactants = list(reactants or []) + compartments = list(compartments or []) + reaction_dict = reaction_dict or {} + assignment_rule_variables = assignment_rule_variables or set() + observable_converted_rules = observable_converted_rules or set() + species_with_conc_functions = species_with_conc_functions or set() + sbml_to_bngl_id = sbml_to_bngl_id or {} + is_saturation = bool(re.search(r"\b(?:Sat|MM|Hill)\s*\(", result)) + + def map_token(match: re.Match) -> str: + token = match.group(1) + end = match.end() + if re.match(r"\s*\(", result[end:]): + return token + mapped = sbml_to_bngl_id.get(token) + if mapped is not None: + observed_name = standardize_name(token) + if is_saturation and observed_name in species_with_conc_functions: + return observed_name + "_amt" + if observed_name in species_with_conc_functions: + return "_c_" + observed_name + "()" + return observed_name + "_amt" + if token in BNGL_LEXER_KEYWORDS and token not in _PROTECTED_BUILTIN_OPERANDS: + return standardize_name(token) + return token + + result = re.sub(r"\b([A-Za-z_][A-Za-z0-9_]*)\b", map_token, result) + result = convert_math_expression(result) + + for compartment in compartments: + result = re.sub( + rf"\b{re.escape(compartment)}\b", + f"__compartment_{standardize_name(compartment)}__", + result, + ) + for reaction_id, reaction_name in reaction_dict.items(): + result = re.sub( + rf"\b{re.escape(reaction_id)}\b", f"netflux_{reaction_name}", result + ) + for variable in assignment_rule_variables: + standard = standardize_name(variable) + if ( + variable in observable_converted_rules + or standard in observable_converted_rules + ): + replacement = standard + elif standard in species_with_conc_functions: + replacement = "_c_" + standard + "()" + else: + replacement = standard + "()" + result = re.sub(rf"\b{re.escape(variable)}\b(?!\s*\()", replacement, result) + + # Sat/MM/Hill are factor-style laws in BNGL. The Playground writer adds + # the first reactant as substrate when the SBML expression omits it. + substrate = standardize_name(reactants[0]) + "_amt" if reactants else "" + if substrate: + result = _replace_nested_function( + result, + "Sat", + lambda args: ( + f"Sat({', '.join(args + [substrate])})" + if len(args) == 2 + else f"Sat({', '.join(args)})" + ), + ) + result = _replace_nested_function( + result, + "MM", + lambda args: ( + f"MM({', '.join(args + [substrate])})" + if len(args) == 2 + else f"MM({', '.join(args)})" + ), + ) + result = _replace_nested_function( + result, + "Hill", + lambda args: ( + f"Hill({', '.join(args + [substrate])})" + if len(args) == 3 + else f"Hill({', '.join(args)})" + ), + ) + return result + + +def curate_parameters(parameters: Mapping[str, object]) -> Dict[str, str]: + """Curate SBML parameter values for BNGL emission. + + This mirrors the Playground writer's public ``curateParameters`` helper: + non-finite literals are made parseable, NaN emits a warning and becomes + zero, and identifiers use the same BNGL-safe spelling as the writer. + """ + + curated: Dict[str, str] = {} + for parameter_id, parameter in parameters.items(): + value = ( + parameter.get("value", "") + if isinstance(parameter, Mapping) + else getattr(parameter, "value", "") + ) + value_text = str(value) + if re.search(r"inf", value_text, flags=re.IGNORECASE): + value_text = re.sub(r"inf", "1e20", value_text, flags=re.IGNORECASE) + if re.search(r"nan", value_text, flags=re.IGNORECASE): + logger.warning( + "BNW001", + f"Parameter {parameter_id} has NaN value, setting to 0", + ) + value_text = "0" + curated[standardize_name(str(parameter_id))] = value_text + return curated + + +def _strip_mass_action_factors(expression: str, reactant_ids: Sequence[str]) -> str: + """Remove explicit SBML species factors from an elementary mass-action law.""" + + pieces = [piece.strip() for piece in re.split(r"\*", expression or "")] + if len(pieces) <= 1: + return expression.strip() or "1" + species_tokens = list(reactant_ids) + remaining: List[str] = [] + removed = 0 + for piece in pieces: + if piece in species_tokens: + species_tokens.remove(piece) + removed += 1 + else: + remaining.append(piece) + if removed != len(reactant_ids): + return expression.strip() or "1" + return " * ".join(remaining) if remaining else "1" + + +def _extract_statistical_factor( + rate: str, reactant_structures: Mapping[str, Species] +) -> str: + """Remove a leading repeated-site factor already represented by BNGL patterns.""" + + match = re.match(r"^\s*\(?\s*(\d+(?:\.\d+)?)\s*\*\s*(.+?)\s*\)?\s*$", rate) + if match is None: + return rate + coefficient = float(match.group(1)) + expected = 1 + for species in reactant_structures.values(): + for molecule in species.molecules: + counts: Dict[str, int] = {} + for component in molecule.components: + counts[component.name] = counts.get(component.name, 0) + 1 + for count in counts.values(): + if count > 1: + expected *= count + if expected > 1 and abs(coefficient - expected) < 1e-9: + return match.group(2).strip() + return rate + + +def _extract_top_level_additive_terms(expression: str) -> List[str]: + """Split an expression at top-level ``+``/``-`` operators.""" + + terms: List[str] = [] + depth = 0 + current_start = 0 + for index, char in enumerate(expression): + if char in "([": + depth += 1 + elif char in ")]": + depth -= 1 + elif ( + depth == 0 + and char in "+-" + and index > 0 + and expression[index - 1] not in "eE*/^([" + ): + term = expression[current_start:index].strip() + if term: + terms.append(term) + current_start = index + last = expression[current_start:].strip() + if last: + terms.append(last) + return terms + + +def _split_reversible_rate(expression: str) -> Optional[Tuple[str, str]]: + """Recover forward and reverse laws from an SBML net reversible rate.""" + + value = expression.strip() + while value.startswith("(") and value.endswith(")"): + depth = 0 + encloses_all = True + for index, char in enumerate(value): + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0 and index < len(value) - 1: + encloses_all = False + break + if not encloses_all: + break + value = value[1:-1].strip() + + positive: List[str] = [] + negative: List[str] = [] + for term in _extract_top_level_additive_terms(value): + term = term.strip() + if term.startswith("-"): + body = term[1:].strip() + if body: + negative.append(body) + elif term.startswith("+"): + body = term[1:].strip() + if body: + positive.append(body) + else: + positive.append(term) + if not positive or not negative: + return None + + def combine(terms: Sequence[str]) -> str: + return ( + terms[0] if len(terms) == 1 else " + ".join(f"({term})" for term in terms) + ) + + return combine(positive), combine(negative) + + +@dataclass(frozen=True) +class ReversibleRateSplit: + """Reference-shaped result for reversible SBML rate decomposition.""" + + success: bool + forward_rate: str + reverse_rate: str + + @property + def forwardRate(self) -> str: + return self.forward_rate + + @property + def reverseRate(self) -> str: + return self.reverse_rate + + +def split_reversible_rate(rate_expression: str) -> ReversibleRateSplit: + """Split a net reversible rate into positive and negative laws.""" + + split = _split_reversible_rate(rate_expression) + if split is None: + return ReversibleRateSplit(False, rate_expression, "0") + return ReversibleRateSplit(True, split[0], split[1]) + + +def _find_top_level_division(expression: str) -> int: + """Return the first division outside parentheses, matching the reference writer.""" + + depth = 0 + for index, character in enumerate(expression): + if character in "([": + depth += 1 + elif character in ")]": + depth -= 1 + elif character == "/" and depth == 0: + return index + return -1 + + +def _has_denominator_issue(neutralized_rate: str, reactant_ids: Sequence[str]) -> bool: + """Detect saturation-like denominators after reactant neutralization. + + The Playground writer's ``split_rxn`` fallback protects a reversible rule + when neutralizing a reactant would turn a denominator such as ``Km + A`` + into ``Km + 1``. Removing that factor would change the rate law, so the + complete net expression must remain on an irreversible functional rule. + """ + + normalized = neutralized_rate + for species_id in reactant_ids: + name = standardize_name(species_id) + normalized = re.sub( + rf"_c_{re.escape(name)}\(\)", "1", normalized, flags=re.IGNORECASE + ) + normalized = re.sub( + rf"\b{re.escape(name)}_amt\b", "1", normalized, flags=re.IGNORECASE + ) + normalized = re.sub( + rf"\b{re.escape(name)}\b", "1", normalized, flags=re.IGNORECASE + ) + + division_index = _find_top_level_division(normalized) + if division_index < 0: + return False + denominator = normalized[division_index + 1 :].strip() + if "+" not in denominator: + return False + return bool( + re.search(r"[\(+]\s*1\s*[+\)]", denominator) + or re.search(r"[\(+]\s*1\s*$", denominator) + ) + + +def _prepared_kinetic_math(reaction: SBMLReaction, model: SBMLModel) -> str: + """Inline functions and substitute reaction-local parameters once.""" + + math_expression = extend_function( + get_kinetic_math(reaction.kinetic_law), + {}, + model.function_definitions, + ) + kinetic_law = reaction.kinetic_law + local_parameters = ( + kinetic_law.get("localParameters", []) + if isinstance(kinetic_law, Mapping) + else getattr(kinetic_law, "local_parameters", []) + ) + for parameter in local_parameters or []: + parameter_id = getattr(parameter, "id", None) + if parameter_id is None and isinstance(parameter, Mapping): + parameter_id = parameter.get("id") + parameter_value = getattr(parameter, "value", None) + if parameter_value is None and isinstance(parameter, Mapping): + parameter_value = parameter.get("value") + if parameter_id: + math_expression = re.sub( + rf"\b{re.escape(str(parameter_id))}\b", + _curated_parameter_value(model, str(parameter_id), parameter_value), + math_expression, + ) + return math_expression + + +def _rate_for_reaction( + reaction: SBMLReaction, + model: SBMLModel, + conversion_factor: Optional[str] = None, + observable_converted_rules: Optional[Set[str]] = None, + reactant_structures: Optional[Mapping[str, Species]] = None, + reactant_ids: Optional[Sequence[str]] = None, + prepared_math: Optional[str] = None, +) -> str: + def apply_conversion(rate: str) -> str: + if conversion_factor is None: + return rate + return f"{conversion_factor} * ({rate})" + + math = ( + prepared_math + if prepared_math is not None + else _prepared_kinetic_math(reaction, model) + ) + if not math or not str(math).strip(): + _log_missing_kinetic( + f"Reaction {standardize_name(reaction.name or reaction.id)} " + f"missing kinetic law; using fallback rate {_MISSING_KINETIC_RATE_FALLBACK}" + ) + return apply_conversion(_MISSING_KINETIC_RATE_FALLBACK) + reactants = ( + list(reactant_ids) + if reactant_ids is not None + else [ + reference.species + for reference in reaction.reactants + if reference.species != "EmptySet" + for _ in range(max(0, int(round(reference.stoichiometry)))) + ] + ) + species_map = {species_id: species_id for species_id in model.species} + assignment_variables = { + rule.variable + for rule in model.rules + if rule.variable and rule.type in {"assignment", "rate"} + } + observable_converted_rules = observable_converted_rules or set() + concentration_names = { + standardize_name(species_id) + for species_id, species in model.species.items() + if not species.has_only_substance_units + } + + # BNG2-exported elementary SBML laws may carry the reaction compartment as + # a leading volume factor (for example ``cell * k * A``). The Playground + # writer removes that factor before normalizing explicit reactant factors; + # otherwise the emitted BNGL rate constant is multiplied by the volume a + # second time. Restrict this bounded cleanup to elementary laws that + # actually mention a reactant, so zero-order fluxes retain their volume + # semantics. + has_saturation = bool(re.search(r"\b(?:Sat|MM|Hill)\s*\(", math)) + nonlinear = has_saturation or "/" in math + if ( + not has_saturation + and reactants + and any( + re.search(rf"\b{re.escape(standardize_name(species_id))}\b", math) + for species_id in reactants + ) + ): + for compartment_id in model.compartments: + standardized = standardize_name(str(compartment_id)) + math = re.sub( + rf"\s*\*\s*__compartment_{re.escape(standardized)}__\s*", + " ", + math, + ) + math = re.sub( + rf"\s*\*\s*{re.escape(str(compartment_id))}\b\s*", + " ", + math, + ) + math = re.sub( + rf"\s*/\s*__compartment_{re.escape(standardized)}__\s*", + " ", + math, + ) + math = re.sub( + rf"\s*/\s*{re.escape(str(compartment_id))}\b\s*", + " ", + math, + ) + math = re.sub( + rf"^\s*__compartment_{re.escape(standardized)}__\s*\*\s*", + "", + math, + ) + math = re.sub( + rf"^\s*{re.escape(str(compartment_id))}\b\s*\*\s*", + "", + math, + ) + math = math.strip() or "1" + nonlinear = has_saturation or "/" in math + + # The Playground writer keeps nonlinear laws intact and maps their species + # operands to concentration functions (or amount observables for Sat/MM/ + # Hill). Only elementary mass-action factors are removed before that + # mapping; stripping a substrate from a saturation or rational law changes + # its biology. + if nonlinear: + return apply_conversion( + bngl_function( + math, + reaction.name or reaction.id, + reactants, + list(model.compartments), + assignment_rule_variables=assignment_variables, + observable_converted_rules=observable_converted_rules, + species_with_conc_functions=concentration_names, + sbml_to_bngl_id=species_map, + ) + ) + + converted = convert_math_expression(math) + stripped = _strip_mass_action_factors(converted, reactants) + if reactant_structures: + stripped = _extract_statistical_factor(stripped, reactant_structures) + return apply_conversion( + bngl_function( + stripped, + reaction.name or reaction.id, + reactants, + list(model.compartments), + assignment_rule_variables=assignment_variables, + observable_converted_rules=observable_converted_rules, + species_with_conc_functions=concentration_names, + sbml_to_bngl_id=species_map, + ) + ) + + +def _record_import_warning( + model: SBMLModel, + message: str, + category: str = "conversionFactor", + severity: str = "approximated", +) -> None: + warnings = getattr(model, "import_warnings", None) + if warnings is None: + model.import_warnings = [] + warnings = model.import_warnings + if any( + warning.get("category") == category and warning.get("message") == message + for warning in warnings + ): + return + warnings.append( + { + "category": category, + "message": message, + "count": 1, + "severity": severity, + } + ) + + +def _curated_parameter_value(model: SBMLModel, parameter_id: str, value: object) -> str: + """Emit finite BNGL literals for SBML's non-finite parameter values.""" + + try: + number = float(value) + except (TypeError, ValueError): + return str(value) + if math.isnan(number): + _record_import_warning( + model, + f'Parameter "{parameter_id}" has NaN value; emitted 0 as a finite ' + "BNGL approximation.", + category="parameter", + severity="approximated", + ) + return "0" + if math.isinf(number): + return "-1e20" if number < 0 else "1e20" + return _number(number) + + +def _inline_reaction_fluxes( + expression: str, + model: SBMLModel, + assignment_rule_variables: Set[str], + observable_converted_rules: Set[str], + species_with_conc_functions: Set[str], + sbml_to_bngl_id: Mapping[str, str], +) -> str: + """Inline reaction IDs used as SBML ``rateOf`` expressions.""" + + if not model.reactions: + return expression + defined = set(model.parameters) | set(model.species) + defined.update( + standardize_name(name) for name in (*model.parameters, *model.species) + ) + defined.update(assignment_rule_variables) + defined.update(standardize_name(name) for name in assignment_rule_variables) + for function_id, function in model.function_definitions.items(): + defined.add(function_id) + defined.add(standardize_name(function_id)) + if getattr(function, "name", ""): + defined.add(function.name) + defined.add(standardize_name(function.name)) + + cache: Dict[str, Optional[str]] = {} + + def reaction_flux(reaction_id: str, reaction: SBMLReaction) -> Optional[str]: + if reaction_id in cache: + return cache[reaction_id] + math_expression = get_kinetic_math(reaction.kinetic_law) + if not math_expression.strip(): + cache[reaction_id] = None + return None + kinetic_law = reaction.kinetic_law + local_parameters = ( + kinetic_law.get("localParameters", []) + if isinstance(kinetic_law, Mapping) + else getattr(kinetic_law, "local_parameters", []) + ) + for parameter in local_parameters or []: + parameter_id = getattr(parameter, "id", None) + if parameter_id is None and isinstance(parameter, Mapping): + parameter_id = parameter.get("id") + parameter_value = getattr(parameter, "value", None) + if parameter_value is None and isinstance(parameter, Mapping): + parameter_value = parameter.get("value") + if parameter_id: + math_expression = re.sub( + rf"\b{re.escape(str(parameter_id))}\b", + _curated_parameter_value(model, str(parameter_id), parameter_value), + math_expression, + ) + try: + math_expression = extend_function( + math_expression, {}, model.function_definitions + ) + flux = bngl_function( + math_expression, + reaction_id, + [], + list(model.compartments), + assignment_rule_variables=assignment_rule_variables, + observable_converted_rules=observable_converted_rules, + species_with_conc_functions=species_with_conc_functions, + sbml_to_bngl_id=sbml_to_bngl_id, + ) + except (TypeError, ValueError, re.error): + flux = None + cache[reaction_id] = flux + return flux + + result = expression + for _ in range(4): + changed = False + for reaction_id, reaction in model.reactions.items(): + reaction_id = str(reaction_id) + if reaction_id in defined or standardize_name(reaction_id) in defined: + continue + flux = reaction_flux(reaction_id, reaction) + if flux is None: + continue + for candidate in (reaction_id, standardize_name(reaction_id)): + pattern = rf"\b{re.escape(candidate)}\b(?!\s*\()" + result, count = re.subn(pattern, lambda _: f"({flux})", result) + if count: + changed = True + break + if not changed: + break + return result + + +def _conversion_factor_for_reaction( + reaction: SBMLReaction, model: SBMLModel +) -> Optional[str]: + """Resolve one BNGL-wide scalar, or report an unrepresentable mixed flux.""" + + model_factor = getattr(model, "conversion_factor", None) or None + effective: List[Optional[str]] = [] + for reference in [*reaction.reactants, *reaction.products]: + if reference.species == "EmptySet": + continue + species = model.species.get(reference.species) + species_factor = ( + getattr(species, "conversion_factor", None) if species else None + ) + effective.append(species_factor or model_factor) + if not effective: + return None + unique = set(effective) + if len(unique) != 1: + _record_import_warning( + model, + f'Reaction "{reaction.id}" has species with differing ' + "conversionFactors; a single BNGL rule cannot apply different " + "scalars per species, so the factor was not applied.", + ) + return None + factor_id = effective[0] + if factor_id is None: + return None + parameter = model.parameters.get(factor_id) + value = getattr(parameter, "value", None) if parameter is not None else None + if isinstance(parameter, Mapping): + value = parameter.get("value") + try: + numeric_value = float(value) + except (TypeError, ValueError): + numeric_value = None + if numeric_value is not None and numeric_value == numeric_value: + return _number(numeric_value) + return standardize_name(factor_id) + + +def write_parameters( + model: SBMLModel, assignment_variables: Optional[Set[str]] = None +) -> List[str]: + assignment_variables = assignment_variables or set() + lines = ["__Avogadro__ 1"] + for compartment_id, compartment in model.compartments.items(): + size = _numeric_value(compartment.size) + lines.append( + f"__compartment_{standardize_name(compartment_id)}__ " + f"{_number(size if size is not None else 1)}" + ) + for parameter_id, parameter in model.parameters.items(): + name = standardize_name(parameter_id) + if name in assignment_variables: + continue + lines.append( + f"{name} {_curated_parameter_value(model, parameter_id, parameter.value)}" + ) + return lines + + +def write_compartments(model: SBMLModel) -> List[str]: + lines = [] + for compartment_id, compartment in model.compartments.items(): + dimension = max(1, int(compartment.spatial_dimensions or 3)) + size = _numeric_value(compartment.size) + line = ( + f"{standardize_name(compartment_id)} {dimension} " + f"{_number(size if size is not None else 1)}" + ) + if compartment.outside and compartment.outside in model.compartments: + parent = model.compartments[compartment.outside] + if int(parent.spatial_dimensions or 3) == dimension + 1: + line += " " + standardize_name(compartment.outside) + lines.append(line) + return lines + + +def write_molecule_types(molecule_types: Sequence[Molecule]) -> List[str]: + lines = [] + for molecule in molecule_types: + # Molecule type declarations define available sites/states, not a + # concrete complex. Remove instance bond labels before writing them. + declaration = molecule.copy() + for component in declaration.components: + component.bonds = [] + value = declaration.str2().split("@", 1)[0] + if "(" not in value: + value += "()" + lines.append("M_" + value) + return sorted(dict.fromkeys(lines)) + + +def _add_seed_symbol(symbols: Dict[str, float], name: object, value: object) -> bool: + number = _numeric_value(value) + if number is None or name is None: + return False + text = str(name) + symbols[text] = number + symbols[standardize_name(text)] = number + return True + + +def _substitute_seed_symbols( + expression: str, symbols: Mapping[str, float] +) -> Optional[float]: + result = convert_math_expression(expression) + for _ in range(8): + changed = False + for symbol, value in list(symbols.items()): + escaped = re.escape(symbol) + result, call_count = re.subn( + rf"\b{escaped}\s*\(\s*\)", _number(value), result + ) + result, bare_count = re.subn( + rf"\b{escaped}\b(?!\s*\()", _number(value), result + ) + changed = changed or bool(call_count or bare_count) + if not changed: + break + return _evaluate_arithmetic(result) + + +def _seed_symbol_values( + model: SBMLModel, seed_species: Sequence[SeedSpeciesEntry] +) -> Dict[str, float]: + """Resolve numeric SBML values needed by expression-valued seed species.""" + + symbols: Dict[str, float] = {} + _add_seed_symbol(symbols, "__Avogadro__", 1) + for parameter_id, parameter in model.parameters.items(): + _add_seed_symbol(symbols, parameter_id, getattr(parameter, "value", None)) + for compartment_id, compartment in model.compartments.items(): + size = getattr(compartment, "size", None) + _add_seed_symbol(symbols, compartment_id, size) + _add_seed_symbol( + symbols, + f"__compartment_{standardize_name(compartment_id)}__", + size, + ) + + species_ids = set(model.species) + standardized_species_ids = { + standardize_name(species_id) for species_id in species_ids + } + + def is_species(identifier: object) -> bool: + value = str(identifier or "") + return ( + value in species_ids or standardize_name(value) in standardized_species_ids + ) + + def add_resolved(identifier: object, expression: object) -> bool: + if identifier is None or not expression: + return False + value = _substitute_seed_symbols(str(expression), symbols) + return _add_seed_symbol(symbols, identifier, value) + + for _ in range(12): + changed = False + for function_id, function in model.function_definitions.items(): + if getattr(function, "arguments", None): + continue + candidates = [function_id, getattr(function, "name", "") or function_id] + if any( + candidate in symbols or standardize_name(candidate) in symbols + for candidate in candidates + ): + continue + value = _substitute_seed_symbols( + str(getattr(function, "math", "") or ""), symbols + ) + if value is not None: + for candidate in candidates: + changed = _add_seed_symbol(symbols, candidate, value) or changed + + for assignment in model.initial_assignments: + symbol = getattr(assignment, "symbol", "") + if is_species(symbol): + continue + if symbol in symbols or standardize_name(symbol) in symbols: + continue + changed = add_resolved(symbol, getattr(assignment, "math", "")) or changed + + for rule in model.rules: + variable = getattr(rule, "variable", None) + if not variable or getattr(rule, "type", "") != "assignment": + continue + if is_species(variable): + continue + if variable in symbols or standardize_name(variable) in symbols: + continue + changed = add_resolved(variable, getattr(rule, "math", "")) or changed + + for seed in seed_species: + identifier = seed.sbml_id + if identifier in symbols or standardize_name(identifier) in symbols: + continue + value = _substitute_seed_symbols(str(seed.concentration), symbols) + if value is None: + continue + changed = _add_seed_symbol(symbols, identifier, value) or changed + species = model.species.get(identifier) + compartment_id = getattr(species, "compartment", "") if species else "" + compartment = model.compartments.get(compartment_id) + size = _numeric_value(getattr(compartment, "size", None)) + if size not in (None, 0): + _add_seed_symbol( + symbols, + f"_c_{standardize_name(identifier)}", + value / size, + ) + if not changed: + break + return symbols + + +def _map_seed_identifiers(expression: str, model: SBMLModel) -> str: + """Map known raw SBML names to the identifiers emitted in BNGL sections.""" + + result = expression + for compartment_id in model.compartments: + result = re.sub( + rf"\b{re.escape(compartment_id)}\b", + f"__compartment_{standardize_name(compartment_id)}__", + result, + ) + for parameter_id in model.parameters: + result = re.sub( + rf"\b{re.escape(parameter_id)}\b", + standardize_name(parameter_id), + result, + ) + for function_id, function in model.function_definitions.items(): + emitted = standardize_name(getattr(function, "name", "") or function_id) + result = re.sub(rf"\b{re.escape(function_id)}\b", emitted, result) + for rule in model.rules: + variable = getattr(rule, "variable", None) + if variable: + result = re.sub( + rf"\b{re.escape(variable)}\b", + standardize_name(variable), + result, + ) + return result + + +def write_seed_species( + seed_species: Sequence[SeedSpeciesEntry], + sct: SpeciesCompositionTable, + model: SBMLModel, +) -> Tuple[List[str], Dict[str, str], Dict[str, str]]: + lines: List[str] = [] + sbml_to_pattern: Dict[str, str] = OrderedDict() + pattern_to_id: Dict[str, str] = OrderedDict() + # Playground writeSeedSpecies coalesces identical patterns while retaining + # separate fixed and dynamic seed declarations. + grouped: "OrderedDict[Tuple[bool, str], Tuple[str, object]]" = OrderedDict() + seed_symbols = _seed_symbol_values(model, seed_species) + for seed in seed_species: + pattern = _pattern(seed.species, seed.compartment) + if not pattern: + continue + # Seed sections use the prefix form, which is accepted by both the + # BNG3 parser and the historical BNG2 writer. + if seed.compartment: + pattern = ( + "@" + + standardize_name(seed.compartment) + + ":" + + pattern.replace("@" + standardize_name(seed.compartment), "") + ) + source_species = model.species.get(seed.sbml_id) + fixed = bool( + source_species + and (source_species.constant or source_species.boundary_condition) + ) + concentration = seed.concentration + if isinstance(concentration, str): + converted = convert_math_expression(concentration) + folded = _substitute_seed_symbols(converted, seed_symbols) + concentration = ( + _number(folded) + if folded is not None + else _map_seed_identifiers(converted, model) + ) + key = (fixed, pattern) + if key not in grouped: + grouped[key] = (pattern, concentration) + sbml_to_pattern[seed.sbml_id] = pattern + pattern_to_id.setdefault(pattern, seed.sbml_id) + for (fixed, _group_pattern), (pattern, concentration) in grouped.items(): + lines.append(f"{'$' if fixed else ''}{pattern} {concentration}") + return lines, sbml_to_pattern, pattern_to_id + + +def write_observables( + model: SBMLModel, + sct: SpeciesCompositionTable, + species_to_pattern: Mapping[str, str], +) -> Tuple[List[str], Dict[str, str]]: + lines = [] + observable_map: Dict[str, str] = OrderedDict() + used = set() + for species_id, species in model.species.items(): + # Expressions are translated from SBML identifiers, not display + # names. Keep observable names on that same key so an SBML species + # such as id="S", name="Substrate" is still referenced as S_amt. + name = standardize_name(species_id) + if name in used: + name = standardize_name(species_id) + while name in used: + name += "_id" + used.add(name) + entry = sct.entries.get(species_id) + pattern = species_to_pattern.get(species_id) + if not pattern and entry is not None: + pattern = _pattern(entry.structure, species.compartment) + if not pattern: + pattern = "M_" + standardize_name(species_id) + "()" + if species.compartment: + pattern = f"@{standardize_name(species.compartment)}:{pattern}" + # Keep both amount and concentration-facing observable names. The + # amount alias is used by saturation laws; the plain name remains the + # historical/public observable used by callers. + lines.append(f"Species {name}_amt {pattern} # {species_id} amount") + lines.append(f"Species {name} {pattern} # {species_id}") + observable_map[species_id] = name + + # A simple assignment rule such as ``total = A + 2 * B`` is a BNGL + # observable, not a dynamic function. Preserve this losslessly when all + # terms resolve to imported species patterns; more complex rules continue + # through the function writer below. + for rule in model.rules: + if rule.type != "assignment" or not rule.variable or not rule.math: + continue + if re.search(r"[/^()]", rule.math): + continue + pattern_counts: Dict[str, int] = OrderedDict() + rule_compartment = "" + for term in rule.math.split("+"): + term = term.strip() + if not term: + continue + coefficient = 1 + species_id = term + match = re.fullmatch(r"(\d+)\s*\*\s*(\S+)", term) + if match: + coefficient, species_id = int(match.group(1)), match.group(2) + else: + match = re.fullmatch(r"(\S+)\s*\*\s*(\d+)", term) + if match: + species_id, coefficient = match.group(1), int(match.group(2)) + if re.fullmatch(r"\d+(?:\.\d+)?", species_id): + continue + pattern = species_to_pattern.get(species_id) + species = model.species.get(species_id) + if not pattern: + entry = sct.entries.get(species_id) + if entry is not None and species is not None: + pattern = _pattern(entry.structure, species.compartment) + if not pattern: + pattern_counts.clear() + break + pattern_counts[pattern] = max(pattern_counts.get(pattern, 0), coefficient) + if species is not None and species.compartment and not rule_compartment: + rule_compartment = species.compartment + if not pattern_counts: + continue + name = standardize_name(rule.variable) + # Remove only the direct species observables with this exact name; + # ``name_amt`` is a distinct observable and must also be replaced. + lines = [ + line + for line in lines + if not line.startswith(f"Species {name} ") + and not line.startswith(f"Species {name}_amt ") + ] + expanded = [] + for pattern, coefficient in pattern_counts.items(): + expanded.extend([pattern] * coefficient) + pattern_string = " ".join(expanded) + lines.append(f"Molecules {name} {pattern_string}") + lines.append(f"Molecules {name}_amt {pattern_string}") + return lines, observable_map + + +def _rewrite_zero_argument_calls(expression: str, names: Iterable[str]) -> str: + result = expression + for name in names: + result = re.sub(rf"\b{re.escape(name)}\b(?!\s*\()", f"{name}()", result) + return result + + +def _inline_constant_function_calls( + function_lines: Sequence[str], parameter_names: Iterable[str] +) -> List[str]: + """Inline constant zero-argument calls before BNG2 reorders functions.""" + + metadata_prefixes = ( + "__rate_rule_pos__", + "__rate_rule_neg__", + "__rate_rule__", + "__assign_rule__", + ) + + def canonical_name(name: str) -> str: + for prefix in metadata_prefixes: + if name.startswith(prefix): + return name[len(prefix) :] + return name + + definition_pattern = re.compile(r"^(\s*)([A-Za-z_]\w*)\(\)\s*=\s*(.+?)\s*$") + definitions: Dict[str, str] = {} + for line in function_lines: + match = definition_pattern.match(line) + if match is None: + continue + name = match.group(2) + if name.startswith("__rate_rule_"): + continue + definitions.setdefault(canonical_name(name), match.group(3)) + if not definitions: + return list(function_lines) + + math_names = { + "exp", + "ln", + "log", + "log10", + "sin", + "cos", + "tan", + "asin", + "acos", + "atan", + "sinh", + "cosh", + "tanh", + "sqrt", + "abs", + "if", + "pow", + "power", + "floor", + "ceil", + "min", + "max", + "rint", + "piecewise", + "and", + "or", + "not", + "time", + } + parameter_names = set(parameter_names) + constant_values: Dict[str, str] = {} + + def inline_known(body: str) -> str: + result = body + for _ in range(8): + changed = False + for name, value in constant_values.items(): + result, count = re.subn( + rf"\b{re.escape(name)}\(\)", f"({value})", result + ) + changed = changed or count > 0 + if not changed: + break + return result + + def constant_body(body: str) -> Optional[str]: + if re.search(r"_c_|_amt|\btime\s*\(", body): + return None + result = inline_known(body) + for match in re.finditer(r"\b([A-Za-z_]\w*)\s*\(\s*\)", result): + if match.group(1) not in math_names: + return None + for match in re.finditer(r"\b([A-Za-z_]\w*)\b(?!\s*\()", result): + name = match.group(1) + if name not in math_names and name not in parameter_names: + return None + return result + + for _ in range(8): + changed = False + for name, body in definitions.items(): + if name in constant_values: + continue + value = constant_body(body) + if value is not None: + constant_values[name] = value + changed = True + if not changed: + break + if not constant_values: + return list(function_lines) + + rewritten: List[str] = [] + for line in function_lines: + match = definition_pattern.match(line) + if match is None: + rewritten.append(line) + continue + name = canonical_name(match.group(2)) + body = constant_values.get(name, inline_known(match.group(3))) + rewritten.append(f"{match.group(1)}{match.group(2)}() = {body}") + return rewritten + + +def _map_compartment_references(expression: str, model: SBMLModel) -> str: + """Map bare SBML compartment IDs to emitted BNGL volume parameters.""" + + result = expression + for compartment_id in model.compartments: + result = re.sub( + rf"\b{re.escape(str(compartment_id))}\b", + f"__compartment_{standardize_name(str(compartment_id))}__", + result, + ) + return result + + +def _ordered_assignment_rules(rules: Sequence[object]) -> List[object]: + """Order assignment rules after their referenced assignment variables.""" + + assignment_rules = [ + rule + for rule in rules + if getattr(rule, "type", "") == "assignment" and getattr(rule, "variable", None) + ] + if len(assignment_rules) < 2: + return assignment_rules + + by_name = {str(rule.variable): rule for rule in assignment_rules} + if len(by_name) != len(assignment_rules): + return assignment_rules + ordered: List[object] = [] + visited: Set[str] = set() + visiting: Set[str] = set() + cycle = False + + def visit(name: str) -> None: + nonlocal cycle + if name in visited: + return + if name in visiting: + cycle = True + return + visiting.add(name) + expression = str(getattr(by_name[name], "math", "") or "") + for dependency in by_name: + if dependency == name: + continue + raw_match = re.search(rf"\b{re.escape(dependency)}\b", expression) + standardized = standardize_name(dependency) + standardized_match = re.search( + rf"\b{re.escape(standardized)}\b", expression + ) + if raw_match or standardized_match: + visit(dependency) + visiting.remove(name) + visited.add(name) + ordered.append(by_name[name]) + + for rule in assignment_rules: + visit(str(rule.variable)) + if cycle: + return assignment_rules + return ordered + + +def write_functions( + model: SBMLModel, + synthetic_rate_rule_variables: Optional[Set[str]] = None, + skip_assignment_rules: Optional[Set[str]] = None, + keep_parameterized: bool = False, +) -> List[str]: + lines = [] + zero_argument_functions = [] + emitted_names: Set[str] = set() + synthetic_rate_rule_variables = synthetic_rate_rule_variables or set() + skip_assignment_rules = skip_assignment_rules or set() + assignment_rule_variables = { + rule.variable + for rule in model.rules + if rule.variable and rule.type in {"assignment", "rate"} + } + rate_rule_variables = { + standardize_name(rule.variable) + for rule in model.rules + if rule.variable and rule.type in {"assignment", "rate"} + } + species_map = {species_id: species_id for species_id in model.species} + for variable in synthetic_rate_rule_variables: + # Synthetic rate-rule state species use the SBML variable as their + # lookup key, while their generated molecule pattern is carried by + # the observable/seed maps in generate_bngl(). + species_map[variable] = variable + species_map[standardize_name(variable)] = variable + concentration_names = { + standardize_name(species_id) + for species_id, species in model.species.items() + if not species.has_only_substance_units + } + + # SBML species are amount-valued in the BNGL observable block. The + # concentration helper mirrors the Playground writer and is required for + # rational/nonlinear kinetic laws in compartments with volume != 1. + for species_id, species in model.species.items(): + name = standardize_name(species_id) + function_name = "_c_" + name + if function_name in emitted_names: + continue + emitted_names.add(function_name) + if species.has_only_substance_units or not species.compartment: + body = name + else: + body = ( + f"{name} / __compartment_" f"{standardize_name(species.compartment)}__" + ) + lines.append(f"{function_name}() = {body}") + + for function_id, function in model.function_definitions.items(): + name = standardize_name(function.name or function_id) + if function.arguments and not keep_parameterized: + # BNG2/BNGL function blocks do not consistently support + # argument-taking SBML definitions. Inline those definitions at + # call sites, as the Playground writer does. + continue + argument_names = [] + body = function.math + for index, argument in enumerate(function.arguments): + base = standardize_name(argument or f"arg{index + 1}") + safe = f"_farg{index}_{base}" + argument_names.append(safe) + if argument and argument != safe: + body = re.sub(rf"\b{re.escape(argument)}\b", safe, body) + args = ", ".join(argument_names) + body = _map_compartment_references(convert_math_expression(body), model) + lines.append(f"{name}({args}) = {body}") + if not function.arguments: + zero_argument_functions.append(name) + emitted_names.add(name) + for rule in _ordered_assignment_rules(model.rules): + if ( + rule.variable in skip_assignment_rules + or standardize_name(rule.variable) in skip_assignment_rules + ): + continue + body = extend_function( + _inline_reaction_fluxes( + rule.math, + model, + assignment_rule_variables, + skip_assignment_rules, + concentration_names, + species_map, + ), + { + parameter_id: parameter.value + for parameter_id, parameter in model.parameters.items() + }, + model.function_definitions, + ) + body = _map_compartment_references(convert_math_expression(body), model) + body = _rewrite_zero_argument_calls(body, zero_argument_functions) + lines.append(f"{standardize_name(rule.variable)}() = {body}") + + for rule in model.rules: + if not rule.variable or rule.type != "rate": + continue + body = extend_function( + _inline_reaction_fluxes( + rule.math, + model, + assignment_rule_variables, + skip_assignment_rules, + concentration_names, + species_map, + ), + {}, + model.function_definitions, + ) + body = bngl_function( + body, + rule.variable, + [], + list(model.compartments), + assignment_rule_variables=rate_rule_variables, + observable_converted_rules=skip_assignment_rules, + species_with_conc_functions=concentration_names, + sbml_to_bngl_id=species_map, + ) + name = standardize_name(rule.variable) + lines.append(f"{RATE_RULE_META_PREFIX}{name}() = {body}") + lines.append( + f"{RATE_RULE_POS_PREFIX}{name}() = if({RATE_RULE_META_PREFIX}{name}() > 0, " + f"{RATE_RULE_META_PREFIX}{name}(), 0)" + ) + lines.append( + f"{RATE_RULE_NEG_PREFIX}{name}() = if({RATE_RULE_META_PREFIX}{name}() < 0, " + f"-({RATE_RULE_META_PREFIX}{name}()), 0)" + ) + return _inline_constant_function_calls(lines, model.parameters) + + +def _reaction_pattern( + species_id: str, + sct: SpeciesCompositionTable, + model: SBMLModel, +) -> str: + entry = sct.entries.get(species_id) + species = model.species.get(species_id) + if entry is not None: + return _pattern(entry.structure, species.compartment if species else "") + name = standardize_name(species.name if species else species_id) + return "M_" + name + "()" + + +def _compartments_are_adjacent( + first_id: Optional[str], + second_id: Optional[str], + compartments: Mapping[str, object], +) -> bool: + if not first_id or not second_id: + return False + first = compartments.get(first_id) + second = compartments.get(second_id) + if first is None or second is None: + return False + return ( + getattr(first, "outside", None) == second_id + or getattr(second, "outside", None) == first_id + ) + + +def write_reaction_rules( + model: SBMLModel, + sct: SpeciesCompositionTable, + atomize: bool, + observable_converted_rules: Optional[Set[str]] = None, + time_rate_functions: Optional[List[str]] = None, +) -> List[str]: + lines = [] + used_labels = set() + for reaction_id, reaction in model.reactions.items(): + reactant_species = ( + model.species.get(reaction.reactants[0].species) + if reaction.reactants + else None + ) + reactant_compartment = ( + getattr(reactant_species, "compartment", None) + if reactant_species is not None + else None + ) + if model.compartments and reactant_compartment: + for reference in reaction.products: + if reference.species == "EmptySet": + continue + product_species = model.species.get(reference.species) + product_compartment = ( + getattr(product_species, "compartment", None) + if product_species is not None + else None + ) + if ( + product_compartment + and product_compartment != reactant_compartment + and not _compartments_are_adjacent( + reactant_compartment, + product_compartment, + model.compartments, + ) + ): + _log_transport_info( + f"Transport reaction {reaction_id}: {reference.species} " + f"moves from {reactant_compartment} to {product_compartment}" + ) + for reference in [*reaction.reactants, *reaction.products]: + if ( + reference.species != "EmptySet" + and reference.variable_stoichiometry + and math.isfinite(reference.stoichiometry) + and reference.stoichiometry >= 0 + and abs(reference.stoichiometry - round(reference.stoichiometry)) + <= 1e-9 + ): + _record_import_warning( + model, + f'Reaction "{reaction_id}" has variable stoichiometry for ' + f'"{reference.species}"; BNGL uses the parsed fixed value ' + f"{reference.stoichiometry:g}.", + category="stoichiometry", + severity="approximated", + ) + unsupported = next( + ( + reference + for reference in [*reaction.reactants, *reaction.products] + if reference.species != "EmptySet" + and ( + not math.isfinite(reference.stoichiometry) + or reference.stoichiometry < 0 + or abs(reference.stoichiometry - round(reference.stoichiometry)) + > 1e-9 + ) + ), + None, + ) + if unsupported is not None: + _record_import_warning( + model, + f'Reaction "{reaction_id}" has unsupported stoichiometry for ' + f'"{unsupported.species}"; the reaction was omitted because BNGL ' + "requires fixed nonnegative integer stoichiometry.", + category="stoichiometry", + severity="dropped", + ) + continue + reactants: List[str] = [] + products: List[str] = [] + for reference in reaction.reactants: + if reference.species == "EmptySet": + continue + reactants.extend( + [_reaction_pattern(reference.species, sct, model)] + * int(round(reference.stoichiometry)) + ) + for reference in reaction.products: + if reference.species == "EmptySet": + continue + products.extend( + [_reaction_pattern(reference.species, sct, model)] + * int(round(reference.stoichiometry)) + ) + label = standardize_name(reaction.name or reaction_id) + candidate = label + suffix = 2 + while candidate in used_labels: + candidate = f"{label}_{suffix}" + suffix += 1 + used_labels.add(candidate) + conversion_factor = _conversion_factor_for_reaction(reaction, model) + structures = { + species_id: entry.structure + for species_id, entry in sct.entries.items() + if entry.structure is not None + } + prepared_math = _prepared_kinetic_math(reaction, model) + split = ( + _split_reversible_rate(convert_math_expression(prepared_math)) + if reaction.reversible + else None + ) + if split is not None: + forward_ids = [ + reference.species + for reference in reaction.reactants + if reference.species != "EmptySet" + for _ in range(max(0, int(round(reference.stoichiometry)))) + ] + reverse_ids = [ + reference.species + for reference in reaction.products + if reference.species != "EmptySet" + for _ in range(max(0, int(round(reference.stoichiometry)))) + ] + forward_rate = _rate_for_reaction( + reaction, + model, + conversion_factor, + observable_converted_rules, + { + species_id: structures[species_id] + for species_id in forward_ids + if species_id in structures + }, + reactant_ids=forward_ids, + prepared_math=split[0], + ) + reverse_rate = _rate_for_reaction( + reaction, + model, + conversion_factor, + observable_converted_rules, + { + species_id: structures[species_id] + for species_id in reverse_ids + if species_id in structures + }, + reactant_ids=reverse_ids, + prepared_math=split[1], + ) + if _has_denominator_issue( + forward_rate, forward_ids + ) or _has_denominator_issue(reverse_rate, reverse_ids): + rate = _rate_for_reaction( + reaction, + model, + conversion_factor, + observable_converted_rules, + structures, + prepared_math=prepared_math, + ) + arrow = "->" + else: + rate = f"{forward_rate}, {reverse_rate}" + arrow = "<->" + else: + arrow = "->" + rate = _rate_for_reaction( + reaction, + model, + conversion_factor, + observable_converted_rules, + structures, + prepared_math=prepared_math, + ) + + # A time-only rate has no species/observable marker for BNG2's + # functional-rate path. Keep it live by emitting a zero-argument + # function, matching the Playground writer's bounded time-rate port. + def needs_time_wrap(value: str) -> bool: + return bool(re.search(r"\btime\s*\(", value)) and not re.search( + r"(?:_amt\b|_c_)", value + ) + + def wrap_time_rate(value: str, suffix: str = "") -> str: + if time_rate_functions is None or not needs_time_wrap(value): + return value + function_name = f"_trate_{candidate}{suffix}" + time_rate_functions.append(f"{function_name}() = {value}") + return f"{function_name}()" + + if arrow == "<->": + depth = 0 + split_at = -1 + for index, character in enumerate(rate): + if character == "(": + depth += 1 + elif character == ")": + depth -= 1 + elif character == "," and depth == 0: + split_at = index + break + if split_at >= 0: + forward = wrap_time_rate(rate[:split_at].strip(), "_f") + reverse = wrap_time_rate(rate[split_at + 1 :].strip(), "_r") + rate = f"{forward}, {reverse}" + else: + rate = wrap_time_rate(rate) + else: + rate = wrap_time_rate(rate) + lines.append( + f"{candidate}: {' + '.join(reactants) if reactants else '0'} " + f"{arrow} {' + '.join(products) if products else '0'} {rate}" + ) + + for rule in model.rules: + if not rule.variable or rule.type != "rate": + continue + target_id = rule.variable + if target_id not in sct.entries and target_id not in model.species: + standardized = standardize_name(target_id) + if standardized in sct.entries: + target_id = standardized + else: + _record_import_warning( + model, + f'Rate rule "{rule.variable}" has no materialized SBML species; ' + "it was retained as metadata but no source/sink rule was emitted.", + ) + continue + pattern = _reaction_pattern(target_id, sct, model) + name = standardize_name(rule.variable) + lines.append( + f"__rate_rule_in_{name}: 0 -> {pattern} {RATE_RULE_POS_PREFIX}{name}()" + ) + lines.append( + f"__rate_rule_out_{name}: {pattern} -> 0 {RATE_RULE_NEG_PREFIX}{name}()" + ) + return lines + + +def generate_bngl( + model: SBMLModel, + sct: SpeciesCompositionTable, + molecule_types: Sequence[Molecule], + seed_species: Sequence[SeedSpeciesEntry], + atomize: bool = False, + actions: str = "", + t_end: float = 10, + n_steps: int = 100, +) -> BNGLGenerationResult: + for rule in model.rules: + if rule.type != "algebraic": + continue + variable = rule.variable or "" + message = ( + f'Algebraic rule "{variable}" is an implicit DAE constraint ' + "with no BNGL equivalent; it was not applied." + ) + if not any( + warning.get("category") == "algebraicRule" + for warning in model.import_warnings + ): + model.import_warnings.append( + { + "category": "algebraicRule", + "message": message, + "count": 1, + "severity": "dropped", + } + ) + assignment_variables = { + standardize_name(rule.variable) + for rule in model.rules + if rule.type == "assignment" and rule.variable + } + + # SBML rate rules may target a parameter or another model variable that + # has no listOfSpecies entry. BNGL can only evolve molecule populations, + # so follow the Playground contract and materialize such targets as + # amount-only synthetic species with a source/sink rule pair below. + augmented_sct_entries: Dict[str, SCTEntry] = OrderedDict(sct.entries) + augmented_sct = SpeciesCompositionTable( + entries=augmented_sct_entries, + dependencies=sct.dependencies, + reverse_dependencies=sct.reverse_dependencies, + sorted_species=list(sct.sorted_species), + weights=list(sct.weights), + ) + augmented_molecule_types = list(molecule_types) + augmented_seed_species = list(seed_species) + synthetic_rate_rule_variables: Set[str] = set() + default_compartment = next(iter(model.compartments), "") + existing_seed_ids = {seed.sbml_id for seed in augmented_seed_species} + existing_molecule_names = { + standardize_name(molecule.name) for molecule in augmented_molecule_types + } + for rule in model.rules: + if not rule.variable or rule.type != "rate": + continue + variable = rule.variable + target = standardize_name(variable) + if ( + variable in model.species + or target in model.species + or variable in existing_seed_ids + or target in existing_seed_ids + ): + continue + + synthetic_rate_rule_variables.add(variable) + molecule_name = f"{SYNTH_RATE_RULE_SPECIES_PREFIX}{target}" + if standardize_name(molecule_name) not in existing_molecule_names: + augmented_molecule_types.append(Molecule(molecule_name)) + existing_molecule_names.add(standardize_name(molecule_name)) + + structure = Species() + structure.add_molecule(Molecule(molecule_name)) + if default_compartment: + structure.add_compartment(default_compartment) + structure.renumber_bonds() + parameter = model.parameters.get(variable) or model.parameters.get(target) + initial_value = getattr(parameter, "value", 0) if parameter else 0 + try: + initial = float(initial_value) + if not math.isfinite(initial): + initial = 0.0 + except (TypeError, ValueError): + initial = 0.0 + augmented_seed_species.append( + SeedSpeciesEntry( + species=structure.copy(), + concentration=_number(initial), + compartment=default_compartment, + sbml_id=variable, + ) + ) + existing_seed_ids.add(variable) + augmented_sct_entries.setdefault( + variable, + SCTEntry( + structure=structure, + components=[], + sbml_id=variable, + is_elemental=True, + modifications={}, + weight=0, + bonds=[], + ), + ) + + if synthetic_rate_rule_variables: + logger.info( + "BNW012", + f"Synthesized {len(synthetic_rate_rule_variables)} rate-rule state species", + ) + + sections = [ + "# BNGL model generated by the Playground-derived Python atomizer", + f"# Model: {model.name}", + f"# Species: {len(model.species)}, Reactions: {len(model.reactions)}", + "", + "begin model", + _section("parameters", write_parameters(model, assignment_variables)), + ] + if model.compartments: + sections.append(_section("compartments", write_compartments(model))) + sections.append( + _section("molecule types", write_molecule_types(augmented_molecule_types)) + ) + seed_lines, species_to_pattern, _pattern_to_id = write_seed_species( + augmented_seed_species, augmented_sct, model + ) + if seed_lines: + sections.append(_section("seed species", seed_lines)) + observable_lines, observable_map = write_observables(model, sct, species_to_pattern) + observable_rule_variables = { + rule.variable + for rule in model.rules + if rule.type == "assignment" + and rule.variable + and any( + line.startswith(f"Molecules {standardize_name(rule.variable)} ") + for line in observable_lines + ) + } + for variable in synthetic_rate_rule_variables: + pattern = species_to_pattern.get(variable) + if pattern: + observable_lines.append( + f"Species {standardize_name(variable)}_amt {pattern}" + ) + if observable_lines: + sections.append(_section("observables", observable_lines)) + function_lines = write_functions( + model, synthetic_rate_rule_variables, observable_rule_variables + ) + time_rate_functions: List[str] = [] + sections.append( + _section( + "reaction rules", + write_reaction_rules( + model, + augmented_sct, + atomize, + observable_converted_rules=observable_rule_variables, + time_rate_functions=time_rate_functions, + ), + ) + ) + function_lines.extend(time_rate_functions) + if function_lines: + sections.insert(-1, _section("functions", function_lines)) + sections.append("end model") + model_text = "\n\n".join(section for section in sections if section != "") + "\n" + + event_result = None + if model.events: + event_result = synthesize_event_actions( + model.events, + EventTranslationContext( + resolve_species_pattern=lambda species_id: species_to_pattern.get( + species_id + ), + resolve_param=lambda identifier: ( + model.parameters[identifier].value + if identifier in model.parameters + else ( + model.compartments[identifier].size + if identifier in model.compartments + else None + ) + ), + is_param=lambda identifier: identifier in model.parameters + or identifier in model.compartments, + method=( + "ssa" + if re.search( + r"simulate_ssa|method\s*=>\s*[\"']?ssa", actions or "", re.I + ) + else "ode" + ), + base_t_end=float(t_end), + base_steps=max(1, int(n_steps)), + ), + ) + + if event_result is not None and ( + event_result.actions_block or event_result.untranslated + ): + notes = ["# ==== SBML DYNAMICS NOTES ====\n"] + if event_result.converted: + notes.append( + f"# {event_result.converted} time-triggered event(s) converted to scheduled actions.\n" + ) + if event_result.untranslated: + notes.append( + "# Events NOT simulated (state-dependent or non-constant); listed for reference:\n" + ) + for event, reason in event_result.untranslated: + label = event.id or event.name or "event" + notes.append(f"# event {label}: {reason}\n") + notes.append(f"# trigger: {event.trigger or '(none)'}\n") + if event.delay: + notes.append(f"# delay: {event.delay}\n") + if getattr(event, "priority", None): + notes.append(f"# priority: {event.priority}\n") + for assignment in event.assignments: + if isinstance(assignment, Mapping): + variable = assignment.get("variable", "") + assignment_math = assignment.get("math", "") + else: + variable = assignment[0] if len(assignment) > 0 else "" + assignment_math = assignment[1] if len(assignment) > 1 else "" + notes.append(f"# assign: {variable} := {assignment_math}\n") + notes.append("# ============================\n") + model_text += "\n" + "".join(notes) + + if event_result is not None and event_result.actions_block: + model_text += "\nbegin actions\n" + model_text += "\n".join( + " " + line if line and not line.startswith("#") else line + for line in event_result.actions_block.splitlines() + ) + model_text += "\nend actions\n" + elif actions: + model_text += "\nbegin actions\n" + model_text += "\n".join(" " + line for line in actions.strip().splitlines()) + model_text += "\nend actions\n" + + has_multi = bool( + model.multi_molecule_types + or model.multi_complex_patterns + or model.multi_seed_patterns + ) + if model.import_warnings or has_multi: + model_text += "\n# ==== SBML IMPORT NOTES ====\n" + for warning in model.import_warnings: + category = warning.get("category", "import") + severity = warning.get("severity", "info") + message = warning.get("message", "") + count = warning.get("count", 1) + suffix = f" (x{count})" if count and count != 1 else "" + model_text += f"# [{severity}] {category}: {message}{suffix}\n" + if model.multi_molecule_types: + model_text += "# SBML Multi molecule-type skeletons (reference only):\n" + for molecule_type in model.multi_molecule_types: + model_text += f"# {molecule_type}\n" + if model.multi_complex_patterns: + model_text += "# SBML Multi bonded complex patterns (reference only):\n" + for pattern in model.multi_complex_patterns: + model_text += f"# {pattern}\n" + if model.multi_seed_patterns: + model_text += "# SBML Multi seed patterns (reference only):\n" + for pattern in model.multi_seed_patterns: + model_text += f"# {pattern}\n" + if has_multi: + model_text += ( + "# Multi structures are not yet fed into the simulated network.\n" + ) + model_text += "# ============================\n" + + model.import_warnings = [ + coerce_import_warning(warning) for warning in model.import_warnings + ] + + warnings = [ + str(warning.get("message", "")) + for warning in model.import_warnings + if warning.get("message") + ] + return BNGLGenerationResult(model_text, observable_map, warnings) + + +# Preserve the TypeScript reference spelling for direct facade callers. +bnglFunction = bngl_function +bnglReaction = bngl_reaction +curateParameters = curate_parameters +generateBNGL = generate_bngl +inlineSBMLFunctions = inline_sbml_functions +splitReversibleRate = split_reversible_rate + + +__all__ = [ + "BNGLGenerationResult", + "bnglFunction", + "bngl_function", + "bnglReaction", + "bngl_reaction", + "convert_math_expression", + "curateParameters", + "curate_parameters", + "extend_function", + "generateBNGL", + "generate_bngl", + "inlineSBMLFunctions", + "inline_sbml_functions", + "ReversibleRateSplit", + "splitReversibleRate", + "split_reversible_rate", + "write_compartments", + "write_functions", + "write_molecule_types", + "write_observables", + "write_parameters", + "write_reaction_rules", + "write_seed_species", +] diff --git a/python/bionetgen/builder.py b/python/bionetgen/builder.py index 20aadf2f..7cf77602 100644 --- a/python/bionetgen/builder.py +++ b/python/bionetgen/builder.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +import json from typing import Sequence from bionetgen.model import BioNetGenModel @@ -96,7 +97,7 @@ def add_action(self, action: str) -> "ModelBuilder": return self def to_bngl(self) -> str: - parts = ["begin model", ""] + parts = [f"setModelName({json.dumps(self.name)})", "", "begin model", ""] if self.parameters: parts.append(_format_block("parameters", self.parameters)) if self.molecule_types: diff --git a/python/bionetgen/cli.py b/python/bionetgen/cli.py index 493030dd..fd421114 100644 --- a/python/bionetgen/cli.py +++ b/python/bionetgen/cli.py @@ -16,7 +16,15 @@ def main(): @main.command() -@click.argument("model", type=click.Path(exists=True)) +@click.argument("model", required=False, type=click.Path(exists=True)) +@click.option( + "--input", + "-i", + "input_path", + default=None, + type=click.Path(exists=True), + help="Legacy PyBioNetGen input path (use with an output directory).", +) @click.option( "--method", "-m", @@ -28,7 +36,7 @@ def main(): "--t-start", default=0.0, type=float, - help="Start time (ODE/SSA/PLA/PSA; NF requires zero).", + help="Absolute simulation start time.", ) @click.option("--t-end", "-t", default=100.0, type=float, help="End time.") @click.option("--n-steps", "-n", default=100, type=int, help="Number of output steps.") @@ -48,6 +56,7 @@ def main(): @click.option("--verbose", "-v", is_flag=True, help="Verbose output.") def run( model, + input_path, method, t_start, t_end, @@ -61,6 +70,25 @@ def run( verbose, ): """Run a BNGL model simulation.""" + if input_path is not None: + if model is not None: + raise click.UsageError("provide either MODEL or --input, not both") + from bionetgen.compat.runner import run as compatibility_run + + try: + result = compatibility_run( + input_path, + out=output or ".", + suppress=not verbose, + ) + except Exception as exc: + raise click.ClickException(str(exc)) from exc + click.echo(f"Results written to {result.path}") + return + + if model is None: + raise click.UsageError("MODEL or --input is required") + from bionetgen import load path = str(Path(model).resolve()) @@ -168,6 +196,148 @@ def export(model, fmt, output): click.echo(f"Exported to {output}") +@main.command() +def info(): + """Show BioNetGen installation and runtime information.""" + import bionetgen + + click.echo(f"BioNetGen version: {bionetgen.__version__}") + click.echo(f"Python package: {Path(bionetgen.__file__).resolve().parent}") + click.echo(f"Python: {sys.executable}") + + +@main.command() +@click.option( + "--input", + "-i", + "input_path", + required=True, + type=click.Path(exists=True, dir_okay=False), + help="Input .gdat, .cdat, or .scan file.", +) +@click.option("--output", "-o", "output_path", default=None, type=click.Path()) +def plot(input_path, output_path): + """Plot a BioNetGen data file.""" + from bionetgen.core.tools.plot import BNGPlotter + + output = Path(output_path) if output_path else Path(input_path).with_suffix(".png") + output.parent.mkdir(parents=True, exist_ok=True) + try: + BNGPlotter(input_path, str(output)).plot() + except Exception as exc: + raise click.ClickException(str(exc)) from exc + click.echo(f"Plot written to {output}") + + +@main.command() +@click.option("--input", "-i", "input_path", default=None, type=click.Path(exists=True)) +@click.option("--output", "-o", "output_path", default=None, type=click.Path()) +@click.option("--open", "open_notebook", is_flag=True, help="Report the notebook path.") +def notebook(input_path, output_path, open_notebook): + """Create a Jupyter notebook from the bundled BioNetGen template.""" + assets = Path(__file__).resolve().parent / "assets" + if input_path: + template = assets / "bionetgen-temp.ipynb" + default_output = Path(input_path).with_suffix(".ipynb") + else: + template = assets / "bionetgen.ipynb" + default_output = Path.cwd() / "bionetgen.ipynb" + output = Path(output_path) if output_path else default_output + if output.exists() and output.is_dir(): + output = output / default_output.name + output.parent.mkdir(parents=True, exist_ok=True) + content = template.read_text(encoding="utf-8") + if input_path: + content = content.replace( + "INPUT_ARG", str(Path(input_path).resolve()).replace("\\", "/") + ) + output.write_text(content, encoding="utf-8") + click.echo(f"Notebook written to {output}") + if open_notebook: + click.echo("Open the notebook with Jupyter or your preferred notebook viewer.") + + +@main.command() +@click.option( + "--input", + "-i", + "input_path", + required=True, + type=click.Path(exists=True, dir_okay=False), +) +@click.option( + "--input2", + "-i2", + "input2_path", + required=True, + type=click.Path(exists=True, dir_okay=False), +) +@click.option("--output", "-o", "output_path", default=None, type=click.Path()) +@click.option("--output2", "-o2", "output2_path", default=None, type=click.Path()) +@click.option("--mode", "-m", default="matrix", type=click.Choice(["matrix", "union"])) +@click.option( + "--colors", "-c", "colors_path", default=None, type=click.Path(exists=True) +) +def graphdiff(input_path, input2_path, output_path, output2_path, mode, colors_path): + """Compare two GraphML visualization outputs.""" + from bionetgen.core.tools.gdiff import BNGGdiff + + for candidate in (output_path, output2_path): + if candidate: + Path(candidate).parent.mkdir(parents=True, exist_ok=True) + try: + BNGGdiff( + input_path, + input2_path, + out=output_path, + out2=output2_path, + mode=mode, + colors=colors_path, + ).run() + except Exception as exc: + raise click.ClickException(str(exc)) from exc + click.echo("Graph diff complete") + + +@main.command() +@click.option( + "--input", + "-i", + "input_path", + required=True, + type=click.Path(exists=True, dir_okay=False), +) +@click.option("--output", "-o", "output_path", default=None, type=click.Path()) +@click.option("--atomize", "-a", is_flag=True, help="Infer molecular structure.") +@click.option( + "--no-conversion", is_flag=True, help="Disable reaction conversion heuristics." +) +@click.option("--no-pathwaycommons", is_flag=True, help="Do not query Pathway Commons.") +def atomize(input_path, output_path, atomize, no_conversion, no_pathwaycommons): + """Translate an SBML file to BNGL through the Python atomizer.""" + from bionetgen import sbml_to_bngl + + try: + text = sbml_to_bngl( + input_path, + atomize=atomize, + no_conversion=no_conversion, + pathwaycommons=not no_pathwaycommons, + ) + except Exception as exc: + raise click.ClickException(str(exc)) from exc + + if output_path is None: + output = Path(input_path).with_suffix(".bngl") + else: + output = Path(output_path) + if output.exists() and output.is_dir(): + output = output / (Path(input_path).stem + ".bngl") + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(text, encoding="utf-8") + click.echo(f"BNGL written to {output}") + + @main.command() @click.argument("model", type=click.Path(exists=True)) @click.option("--parameter", "parameter_name", required=True, help="Parameter to scan.") @@ -354,9 +524,18 @@ def sensitivity( @main.command() -@click.argument("model", type=click.Path(exists=True)) +@click.argument("model", required=False, type=click.Path(exists=True)) +@click.option( + "--input", + "-i", + "input_path", + default=None, + type=click.Path(exists=True), + help="Legacy input model path.", +) @click.option( "--type", + "-t", "viz_type", default="contact_map", type=click.Choice( @@ -369,6 +548,10 @@ def sensitivity( "ruleviz_operation", "process_graph", "sbml_multi", + "contactmap", + "regulatory", + "atom_rule", + "all", ] ), show_default=True, @@ -377,17 +560,59 @@ def sensitivity( @click.option( "--output", "-o", default=None, type=click.Path(), help="Output GraphML path." ) -def visualize(model, viz_type, output): +def visualize(model, input_path, viz_type, output): """Generate a model visualization graph.""" from bionetgen import load + if input_path is not None: + if model is not None: + raise click.UsageError("provide either MODEL or --input, not both") + model = input_path + if model is None: + raise click.UsageError("MODEL or --input is required") + bng_model = load(model) - graph_text = getattr(bng_model, viz_type)(output) - if output is None: + aliases = { + "contactmap": "contact_map", + "regulatory": "regulatory_graph", + "atom_rule": "regulatory_graph", + } + canonical = aliases.get(viz_type, viz_type) + if canonical == "all": + output_dir = Path(output) if output else Path.cwd() + if output and output_dir.suffix: + raise click.ClickException( + "--output for visualize --type all must be a directory" + ) + output_dir.mkdir(parents=True, exist_ok=True) + methods = [ + "contact_map", + "regulatory_graph", + "ruleviz_pattern", + "ruleviz_operation", + ] + for method in methods: + destination = output_dir / f"{method}.graphml" + getattr(bng_model, method)(str(destination)) + click.echo(f"Wrote {len(methods)} visualizations to {output_dir}") + return + + destination = None + if output: + destination = Path(output) + if destination.exists() and destination.is_dir(): + destination = destination / f"{canonical}.graphml" + elif not destination.exists() and not destination.suffix: + destination.mkdir(parents=True, exist_ok=True) + destination = destination / f"{canonical}.graphml" + graph_text = getattr(bng_model, canonical)( + None if destination is None else str(destination) + ) + if destination is None: click.echo(graph_text) else: - click.echo(f"Wrote {viz_type} to {output}") + click.echo(f"Wrote {canonical} to {destination}") if __name__ == "__main__": diff --git a/python/bionetgen/compat/runner.py b/python/bionetgen/compat/runner.py new file mode 100644 index 00000000..d51afb0b --- /dev/null +++ b/python/bionetgen/compat/runner.py @@ -0,0 +1,122 @@ +"""Explicit PyBioNetGen-compatible file runner backed by BNG3.""" + +from __future__ import annotations + +from pathlib import Path +import shutil +from tempfile import TemporaryDirectory + +from bionetgen.core.exc import BNGError +from bionetgen.core.tools.result import BNGResult + + +def _run_in_directory( + inp: Path, + output: Path, + *, + suppress: bool, + method: str | None, + timeout: int | None, +) -> BNGResult: + if inp.suffix.lower() != ".bngl": + raise NotImplementedError( + "The BNG3 compatibility runner currently accepts BNGL input only" + ) + if method is not None: + raise NotImplementedError( + "method overrides are not supported by the compatibility runner; " + "edit the model actions or use load(...).simulate(...)" + ) + if timeout is not None: + raise NotImplementedError( + "timeout is not supported by the in-process BNG3 compatibility runner" + ) + + output.mkdir(parents=True, exist_ok=True) + copied = output / inp.name + if copied.resolve() != inp.resolve(): + shutil.copy2(inp, copied) + + try: + from bionetgen import _bionetgen_cpp as cpp + except ImportError as exc: + raise BNGError( + "The BNG3 C++ backend is required for bionetgen.run(input, out)" + ) from exc + + try: + model = cpp.parse_file(str(copied)) + cpp.execute(model, str(copied), verbose=not suppress) + except Exception as exc: + raise BNGError(f"BNG3 failed while running {inp}: {exc}") from exc + + result = BNGResult(path=str(output)) + result.process_return = 0 + result.output = ["Ran successfully via BNG3"] + return result + + +def run( + inp, + out=None, + suppress=False, + timeout=None, + simulator="auto", + format=None, + method=None, + t_span=None, + n_points=None, + t_end=100.0, + n_steps=100, + **kwargs, +): + """Run a BNGL file into a PyBioNetGen-style result directory. + + This first compatibility slice intentionally preserves the input model's + declared actions. Unsupported legacy options fail explicitly rather than + silently selecting a different simulator or output contract. + """ + if kwargs: + names = ", ".join(sorted(kwargs)) + raise TypeError(f"unsupported compatibility runner option(s): {names}") + if simulator != "auto": + raise NotImplementedError( + "only simulator='auto' is supported by the BNG3 compatibility runner" + ) + if format not in (None, "bngl"): + raise NotImplementedError( + "only BNGL input is supported by the BNG3 compatibility runner" + ) + if t_span is not None or n_points is not None: + raise NotImplementedError( + "t_span/n_points overrides are not supported while preserving actions" + ) + if t_end != 100.0 or n_steps != 100: + raise NotImplementedError( + "t_end/n_steps overrides are not supported while preserving actions" + ) + + inp_path = Path(inp).expanduser().resolve() + if not inp_path.is_file(): + raise FileNotFoundError(inp_path) + + if out is None: + with TemporaryDirectory(prefix="bionetgen-run-") as temp: + return _run_in_directory( + inp_path, + Path(temp), + suppress=suppress, + method=method, + timeout=timeout, + ) + + output = Path(out).expanduser().resolve() + if output.exists() and not output.is_dir(): + raise NotADirectoryError(output) + return _run_in_directory( + inp_path, + output, + suppress=suppress, + method=method, + timeout=timeout, + ) diff --git a/python/bionetgen/core/tools/cli.py b/python/bionetgen/core/tools/cli.py index 9543aef0..7567c354 100644 --- a/python/bionetgen/core/tools/cli.py +++ b/python/bionetgen/core/tools/cli.py @@ -97,9 +97,14 @@ def _set_output(self, output): def run(self): self.logger.debug("Running", loc=f"{__file__} : BNGCLI.run()") - # C++ fast path + # C++ fast path. Import availability is the only fallback boundary; + # parse or execution errors must not silently change engines. try: from bionetgen import _bionetgen_cpp as _cpp + except ImportError: + _cpp = None + + if _cpp is not None: import shutil # Determine path to run on @@ -128,26 +133,22 @@ def run(self): self.result.process_return = 0 self.result.output = ["Ran successfully via C++ backend"] return - except ImportError: - self.logger.debug("C++ backend not available, falling back to Perl") - except Exception as e: - self.logger.warning(f"C++ backend failed: {e}, falling back to Perl") - # If BNG2.pl is not available, fall back to an empty result so that - # library users can still instantiate and inspect models without a - # full BioNetGen install. - if self.bng_exec is None: - from bionetgen.core.tools import BNGResult + self.logger.debug("C++ backend not available, using explicit legacy Perl path") - self.result = BNGResult(self.output) - self.result.process_return = 0 - self.result.output = [] - if self.old_bngpath is not None: - os.environ["BNGPATH"] = self.old_bngpath - else: - if "BNGPATH" in os.environ: - del os.environ["BNGPATH"] - return + # A missing legacy executable is an error, not a successful empty run. + if self.bng_exec is None: + raise BNGRunError( + [ + "perl", + "BNG2.pl", + self.inp_path if not self.is_bngmodel else "", + ], + message=( + "Neither the in-process BNG3 backend nor the legacy BNG2.pl " + "compatibility executable is available." + ), + ) from bionetgen.core.utils.utils import run_command diff --git a/python/bionetgen/model.py b/python/bionetgen/model.py index d958f97f..02424300 100644 --- a/python/bionetgen/model.py +++ b/python/bionetgen/model.py @@ -158,6 +158,17 @@ def simulate( pla_config: str = "", psa_poplevel: float = 100.0, verbose: bool = False, + max_step: float = 0.0, + steady_state: bool = False, + steady_state_tol: Optional[float] = None, + stop_if: str = "", + sample_times=None, + max_sim_steps: int = 0, + output_step_interval: int = 0, + sparse: bool = False, + check_product_scale: float = 0.0, + equilibrate: float = 0.0, + traversal_limit: int = -1, ) -> SimResult: """Run a simulation using the specified method. @@ -170,8 +181,8 @@ def simulate( n_steps : int Number of output time steps. t_start : float - Start time for ODE, SSA, PLA, or PSA simulation. NF currently - supports only the default start time of zero. + Absolute start time for the simulation. NF uses this time for + time-dependent functions and output coordinates. rtol, atol : float Relative and absolute tolerances (ODE only). seed : int @@ -182,6 +193,31 @@ def simulate( Population level threshold for PSA (PSA only). verbose : bool Print progress information. + max_step : float + Maximum internal CVODE step size (ODE only; 0 disables the limit). + steady_state : bool + Stop an ODE run when the derivative norm is below the tolerance. + steady_state_tol : float, optional + Derivative tolerance for steady-state stopping. + stop_if : str + Expression evaluated at output points/events; a nonzero value stops + ODE/SSA simulation. + sample_times : sequence of float, optional + Strictly increasing ODE/SSA/NF output times between ``t_start`` + and ``t_end``. If the final time is omitted, ``t_end`` is appended. + max_sim_steps : int + Maximum internal SSA reaction events (0 = unlimited). + output_step_interval : int + Output every N internal SSA reaction events when ``sample_times`` + is not set. + sparse : bool + Request the sparse CVODE linear solver. + check_product_scale : float + Warn when an ODE species exceeds this positive threshold. + traversal_limit : int + NFsim bonded-neighborhood traversal depth. ``-1`` uses the + adapter's model-derived recommendation; non-negative values mirror + NFsim's ``-utl`` control. NF only. Returns ------- @@ -206,24 +242,124 @@ def simulate( n_steps = operator.index(n_steps) except TypeError as exc: raise TypeError("n_steps must be a positive integer") from exc - if n_steps <= 0: - raise ValueError("n_steps must be a positive integer") + if n_steps < 0 or (n_steps == 0 and sample_times is None): + raise ValueError("n_steps must be positive unless sample_times is set") if not math.isfinite(float(rtol)) or rtol <= 0.0: raise ValueError("rtol must be finite and positive") if not math.isfinite(float(atol)) or atol <= 0.0: raise ValueError("atol must be finite and positive") if not math.isfinite(float(psa_poplevel)): raise ValueError("psa_poplevel must be finite") + if not math.isfinite(float(max_step)) or max_step < 0.0: + raise ValueError("max_step must be finite and non-negative") + if steady_state_tol is None: + steady_state_tol = atol + if not math.isfinite(float(steady_state_tol)) or steady_state_tol <= 0.0: + raise ValueError("steady_state_tol must be finite and positive") + if not isinstance(stop_if, str): + raise TypeError("stop_if must be a string") + if not math.isfinite(float(check_product_scale)) or check_product_scale < 0.0: + raise ValueError("check_product_scale must be finite and non-negative") + if not math.isfinite(float(equilibrate)) or equilibrate < 0.0: + raise ValueError("equilibrate must be finite and non-negative") + if isinstance(traversal_limit, bool): + raise TypeError("traversal_limit must be -1 or a non-negative integer") + try: + traversal_limit = operator.index(traversal_limit) + except TypeError as exc: + raise TypeError( + "traversal_limit must be -1 or a non-negative integer" + ) from exc + if traversal_limit < -1: + raise ValueError("traversal_limit must be -1 or a non-negative integer") + if method != "nf" and equilibrate != 0.0: + raise ValueError("equilibrate is supported only for method='nf'") + if method != "nf" and traversal_limit != -1: + raise ValueError("traversal_limit is supported only for method='nf'") + for option_name, option_value in { + "max_sim_steps": max_sim_steps, + "output_step_interval": output_step_interval, + }.items(): + if isinstance(option_value, bool): + raise TypeError(f"{option_name} must be a non-negative integer") + try: + option_value = operator.index(option_value) + except TypeError as exc: + raise TypeError( + f"{option_name} must be a non-negative integer" + ) from exc + if option_value < 0: + raise ValueError(f"{option_name} must be a non-negative integer") + if option_name == "max_sim_steps": + max_sim_steps = option_value + else: + output_step_interval = option_value + + normalized_sample_times = [] + if sample_times is not None: + try: + normalized_sample_times = [float(value) for value in sample_times] + except (TypeError, ValueError) as exc: + raise TypeError("sample_times must be a sequence of numbers") from exc + if not normalized_sample_times: + raise ValueError("sample_times must not be empty") + if any(not math.isfinite(value) for value in normalized_sample_times): + raise ValueError("sample_times must contain only finite values") + if any( + later <= earlier + for earlier, later in zip( + normalized_sample_times, normalized_sample_times[1:] + ) + ): + raise ValueError("sample_times must be strictly increasing") + if ( + normalized_sample_times[0] < t_start + or normalized_sample_times[-1] > t_end + ): + raise ValueError("sample_times must lie between t_start and t_end") + if normalized_sample_times[-1] < t_end: + normalized_sample_times.append(float(t_end)) + if n_steps == 0 and not normalized_sample_times: + raise ValueError("n_steps must be positive unless sample_times is set") + + ode_only_options = ( + max_step > 0.0 or steady_state or sparse or check_product_scale > 0.0 + ) + if method != "ode" and ode_only_options: + raise ValueError( + "max_step, steady-state, sparse, and check_product_scale are " + "supported only for method='ode'" + ) + if method == "ode" and (max_sim_steps or output_step_interval): + raise ValueError( + "max_sim_steps and output_step_interval are supported only " + "for method='ssa'" + ) + if method not in {"ode", "ssa", "nf"} and ( + stop_if or max_sim_steps or output_step_interval + ): + raise ValueError( + "stop_if, max_sim_steps, and output_step_interval are supported " + "only for method='ode' or method='ssa'" + ) + if method == "nf" and (stop_if or max_sim_steps or output_step_interval): + raise ValueError( + "stop_if, max_sim_steps, and output_step_interval are supported " + "only for method='ode' or method='ssa'" + ) if method == "nf": - if t_start != 0.0: - raise ValueError("NF simulation currently supports only t_start=0.0") raw = _cpp.simulate_nf( self._model, t_end=t_end, n_steps=n_steps, + t_start=t_start, seed=seed, verbose=verbose, + equilibrate=equilibrate, + source_path=self._source_path or "", + sample_times=normalized_sample_times, + traversal_limit=traversal_limit, ) else: if self._network is None: @@ -238,6 +374,16 @@ def simulate( t_start=t_start, rtol=rtol, atol=atol, + method="cvode", + max_step=max_step, + steady_state=steady_state, + steady_state_tol=steady_state_tol, + stop_if=stop_if, + sample_times=normalized_sample_times, + max_sim_steps=max_sim_steps, + output_step_interval=output_step_interval, + sparse=sparse, + check_product_scale=check_product_scale, ) elif method == "ssa": raw = _cpp.simulate_ssa( @@ -247,6 +393,10 @@ def simulate( n_steps=n_steps, t_start=t_start, seed=seed, + stop_if=stop_if, + sample_times=normalized_sample_times, + max_sim_steps=max_sim_steps, + output_step_interval=output_step_interval, ) elif method == "pla": raw = _cpp.simulate_pla( diff --git a/python/bionetgen/modelapi/bngfile.py b/python/bionetgen/modelapi/bngfile.py index 621a1680..aa361ded 100644 --- a/python/bionetgen/modelapi/bngfile.py +++ b/python/bionetgen/modelapi/bngfile.py @@ -68,6 +68,31 @@ def generate_xml(self, xml_file, model_file=None) -> bool: try: # make a stripped copy without actions in the folder stripped_bngl = self.strip_actions(model_file, temp_folder) + + # The legacy model API consumes BNG-XML, but the canonical BNG3 + # parser can produce that representation without invoking Perl. + # Keep the stripped copy so parsed_actions retains its historical + # behavior for the caller. + try: + from bionetgen import _bionetgen_cpp as cpp + except ImportError: + cpp = None + if cpp is not None: + cpp_model = cpp.parse_file(stripped_bngl) + if not cpp_model.model_name: + cpp_model.set_model_name( + os.path.splitext(os.path.basename(model_file))[0] + ) + content = cpp.io.write_xml_string(cpp_model) + try: + xml_file.seek(0) + xml_file.truncate(0) + except (AttributeError, OSError): + pass + xml_file.write(content) + xml_file.seek(0) + return True + # run with --xml os.chdir(temp_folder) # If BNG2.pl is not available, fall back to a minimal in-Python XML @@ -201,14 +226,64 @@ def _not_action(self, line) -> bool: def write_xml(self, open_file, xml_type="bngxml", bngl_str=None) -> bool: """ - write new BNG-XML or SBML of file by calling BNG2.pl again - or can take BNGL string in as well. + Write BNG-XML or SBML for this file, or for an explicit BNGL string. + + When the compiled BNG3 backend is available, both inputs use the + canonical parser and writers. The Perl route remains available only + as a compatibility fallback for environments that cannot import the + compiled backend. """ - # TODO: Implement the route where this function uses the file itself - # for this generation if bngl_str is None: - # should load in the right str here - raise NotImplementedError + try: + with open(self.path, "r", encoding="UTF-8") as model_file: + bngl_str = model_file.read() + except OSError as exc: + raise BNGFileError( + self.path, message=f"Could not read BNGL file: {exc}" + ) from exc + + xml_type = str(xml_type).lower() + + # Keep the compatibility facade on the same canonical implementation + # as the modern Model API whenever the extension is present. + try: + from bionetgen import _bionetgen_cpp as cpp + except ImportError: + cpp = None + + if cpp is not None: + if xml_type not in {"bngxml", "sbml"}: + print("XML type {} not recognized".format(xml_type)) + return False + + cpp_model = cpp.parse_string(bngl_str) + if xml_type == "bngxml": + content = cpp.io.write_xml_string(cpp_model) + else: + network = cpp.generate_network(cpp_model) + with tempfile.TemporaryDirectory(prefix="pybng-") as temp_folder: + sbml_path = os.path.join(temp_folder, "model.xml") + cpp.io.write_sbml(cpp_model, network, sbml_path) + with open(sbml_path, "r", encoding="UTF-8") as sbml_file: + content = sbml_file.read() + + try: + open_file.seek(0) + open_file.truncate(0) + except (AttributeError, OSError): + pass + open_file.write(content) + open_file.seek(0) + return True + + if self.bngexec is None: + raise BNGFileError( + self.path, + message=( + "BNG3 compiled backend is unavailable and no BNG2.pl " + "compatibility executable was found" + ), + ) cur_dir = os.getcwd() # temporary folder to work in diff --git a/python/bionetgen/modelapi/runner.py b/python/bionetgen/modelapi/runner.py index a3b139ef..06938a4c 100644 --- a/python/bionetgen/modelapi/runner.py +++ b/python/bionetgen/modelapi/runner.py @@ -12,6 +12,20 @@ logger = logging.getLogger(__name__) +def _load_cpp_backend(): + """Return the in-process backend when installed, otherwise ``None``. + + Backend import availability is the only condition that permits the + legacy subprocess path. Parse and execution errors must remain visible; + otherwise a real BNG3 failure can be silently replaced by Perl behavior. + """ + try: + from bionetgen import _bionetgen_cpp as cpp + except ImportError: + return None + return cpp + + def run(inp, out=None, suppress=False, timeout=None): """ Convenience function to run BNG2.pl as a library @@ -26,14 +40,12 @@ def run(inp, out=None, suppress=False, timeout=None): (optional) this points to a folder to put the results into. If it doesn't exist, it will be created. """ - # Try C++ backend first - try: - from bionetgen import _bionetgen_cpp as _cpp - - model = _cpp.parse_file(inp) - return _cpp.execute(model, inp) - except (ImportError, AttributeError, Exception): - pass + # Try C++ backend first. Only an unavailable extension permits the + # legacy subprocess path; backend failures are product failures. + cpp = _load_cpp_backend() + if cpp is not None: + model = cpp.parse_file(inp) + return cpp.execute(model, inp) # Fall back to the existing BNGCLI subprocess approach cur_dir = os.getcwd() diff --git a/python/bionetgen/modelapi/sympy_odes.py b/python/bionetgen/modelapi/sympy_odes.py index 0357516f..f6a6c87c 100644 --- a/python/bionetgen/modelapi/sympy_odes.py +++ b/python/bionetgen/modelapi/sympy_odes.py @@ -2,6 +2,7 @@ import glob import os +from pathlib import Path import re import tempfile from dataclasses import dataclass @@ -32,6 +33,52 @@ class SympyOdes: ] +def _export_modern_sympy_odes( + model_or_path, + out_dir: Optional[str], + mex_suffix: str, + keep_files: bool, +) -> SympyOdes: + """Generate MEX source from the canonical C++ model and parse it.""" + from bionetgen.model import BioNetGenModel, load + + if isinstance(model_or_path, BioNetGenModel): + model = model_or_path + else: + model = load(model_or_path) + + try: + from bionetgen import _bionetgen_cpp as cpp + except ImportError as exc: # pragma: no cover - guarded by package setup + raise ImportError("The compiled _bionetgen_cpp extension is required") from exc + + network = model.generate_network() + if model.source_path: + model_stem = Path(model.source_path).stem + else: + model_stem = model.name or "model" + output_root = ( + Path(out_dir) + if out_dir is not None + else Path(tempfile.mkdtemp(prefix="pybng_sympy_")) + ) + cleanup = out_dir is None and not keep_files + output_root.mkdir(parents=True, exist_ok=True) + output_name = model_stem + if mex_suffix: + output_name += f"_{mex_suffix}" + output_path = output_root / f"{output_name}_mex.c" + + try: + output_path.write_text( + cpp.io.write_mex_string(model._model, network), encoding="utf-8" + ) + return extract_odes_from_mexfile(str(output_path)) + finally: + if cleanup: + _safe_rmtree(str(output_root)) + + def export_sympy_odes( model_or_path, out_dir: Optional[str] = None, @@ -45,6 +92,15 @@ def export_sympy_odes( Returns a SympyOdes object containing SymPy symbols and expressions. """ from bionetgen.modelapi.model import bngmodel + + if not isinstance(model_or_path, bngmodel): + return _export_modern_sympy_odes( + model_or_path, + out_dir=out_dir, + mex_suffix=mex_suffix, + keep_files=keep_files, + ) + from bionetgen.modelapi.runner import run if isinstance(model_or_path, bngmodel): @@ -100,6 +156,8 @@ def extract_odes_from_mexfile(mex_c_path: str) -> SympyOdes: # format first. if "calc_species_deriv" in text and "NV_Ith_S(Dspecies" in text: return _extract_odes_from_cvode_mex(text, mex_c_path) + if "calc_species_deriv" in text and re.search(r"\bdydt\s*\[", text): + return _extract_odes_from_cpp_mex(text, mex_c_path) species_names = _extract_name_array(text, _NAME_ARRAY_PATTERNS) param_names = _extract_name_array(text, _PARAM_ARRAY_PATTERNS) @@ -289,6 +347,122 @@ def _parse_rhs(rhs: str) -> sp.Expr: ) +def _extract_odes_from_cpp_mex(text: str, mex_c_path: str) -> SympyOdes: + """Parse the standalone C++ MEX format emitted by BNG3's MexWriter.""" + n_species = _extract_define_int_any(text, "N_SPECIES") + n_params = _extract_define_int_any(text, "N_PARAMETERS") + n_expr = _extract_define_int_any(text, "N_EXPRESSIONS") + n_obs = _extract_define_int_any(text, "N_OBSERVABLES") + n_rate = _extract_define_int_any(text, "N_RATELAWS") + + expr_map = _extract_array_assignments( + _extract_function_body(text, "calc_expressions"), "expressions" + ) + obs_map = _extract_array_assignments( + _extract_function_body(text, "calc_observables"), "observables" + ) + rate_map = _extract_array_assignments( + _extract_function_body(text, "calc_ratelaws"), "ratelaws" + ) + deriv_map = _extract_array_assignments( + _extract_function_body(text, "calc_species_deriv"), "dydt" + ) + if not deriv_map: + raise ValueError( + "No ODE assignments found in BNG3 MEX output. " + "Expected dydt[index] assignments in calc_species_deriv." + ) + + n_species = n_species or (max(deriv_map) + 1) + n_params = n_params or _max_cpp_reference_index(text, "parameters") + n_expr = n_expr or (max(expr_map) + 1 if expr_map else n_params) + n_obs = n_obs or (max(obs_map) + 1 if obs_map else 0) + n_rate = n_rate or (max(rate_map) + 1 if rate_map else 0) + + species_symbol_names, species_names = _build_symbol_names([], n_species, prefix="s") + param_symbol_names, param_names = _build_symbol_names([], n_params, prefix="p") + species_symbols = [sp.Symbol(name) for name in species_symbol_names] + param_symbols = [sp.Symbol(name) for name in param_symbol_names] + expr_syms = [sp.Symbol(f"e{i}") for i in range(n_expr)] + obs_syms = [sp.Symbol(f"o{i}") for i in range(n_obs)] + rate_syms = [sp.Symbol(f"r{i}") for i in range(n_rate)] + t = sp.Symbol("t") + + local_dict: Dict[str, object] = {s.name: s for s in species_symbols} + local_dict.update({p.name: p for p in param_symbols}) + local_dict.update({e.name: e for e in expr_syms}) + local_dict.update({o.name: o for o in obs_syms}) + local_dict.update({r.name: r for r in rate_syms}) + local_dict.update( + { + "Pow": sp.Pow, + "Abs": sp.Abs, + "Max": sp.Max, + "Min": sp.Min, + "exp": sp.exp, + "log": sp.log, + "sqrt": sp.sqrt, + "pi": sp.pi, + } + ) + + def parse_rhs(rhs: str) -> sp.Expr: + cleaned = _normalize_expr(rhs) + cleaned = _replace_cpp_array_symbols( + cleaned, + species_symbol_names, + param_symbol_names, + expr_syms, + obs_syms, + rate_syms, + ) + return cast( + sp.Expr, + parse_expr( + cleaned, + local_dict=local_dict, + transformations=standard_transformations, + ), + ) + + expr_exprs: List[sp.Expr] = [sp.Integer(0) for _ in range(n_expr)] + for idx in sorted(expr_map): + expr_exprs[idx] = cast( + sp.Expr, + parse_rhs(expr_map[idx]).subs( + {expr_syms[j]: expr_exprs[j] for j in range(idx)} + ), + ) + + expr_sub = {expr_syms[i]: expr_exprs[i] for i in range(n_expr)} + obs_exprs: List[sp.Expr] = [sp.Integer(0) for _ in range(n_obs)] + for idx in sorted(obs_map): + obs_exprs[idx] = cast(sp.Expr, parse_rhs(obs_map[idx]).subs(expr_sub)) + obs_sub = {obs_syms[i]: obs_exprs[i] for i in range(n_obs)} + + rate_exprs: List[sp.Expr] = [sp.Integer(0) for _ in range(n_rate)] + for idx in sorted(rate_map): + rate_exprs[idx] = cast( + sp.Expr, parse_rhs(rate_map[idx]).subs(expr_sub).subs(obs_sub) + ) + rate_sub = {rate_syms[i]: rate_exprs[i] for i in range(n_rate)} + + odes: List[sp.Expr] = [sp.Integer(0) for _ in range(n_species)] + for idx in range(n_species): + if idx in deriv_map: + odes[idx] = cast(sp.Expr, parse_rhs(deriv_map[idx]).subs(rate_sub)) + + return SympyOdes( + t=t, + species=species_symbols, + params=param_symbols, + odes=odes, + species_names=species_names, + param_names=param_names, + source_path=mex_c_path, + ) + + def _extract_define_int(text: str, define_name: str) -> Optional[int]: m = re.search( rf"^\s*#define\s+{re.escape(define_name)}\s+(\d+)\s*$", text, flags=re.M @@ -321,6 +495,63 @@ def _extract_nv_assignments(body: str, lhs_var: str) -> Dict[int, str]: return eq_map +def _extract_array_assignments(body: str, lhs_var: str) -> Dict[int, str]: + if not body: + return {} + eq_map: Dict[int, str] = {} + pattern = rf"\b{re.escape(lhs_var)}\s*\[\s*(\d+)\s*\]\s*=\s*(.*?);" + for match in re.finditer(pattern, body, flags=re.S): + eq_map[int(match.group(1))] = match.group(2).strip() + return eq_map + + +def _extract_define_int_any(text: str, define_name: str) -> Optional[int]: + match = re.search( + rf"^\s*#define\s+{re.escape(define_name)}\s+(\d+)\s*$", + text, + flags=re.M, + ) + return int(match.group(1)) if match else None + + +def _max_cpp_reference_index(text: str, array_name: str) -> int: + indices = [ + int(match.group(1)) + for match in re.finditer(rf"\b{re.escape(array_name)}\s*\[\s*(\d+)\s*\]", text) + ] + return max(indices, default=-1) + 1 + + +def _replace_cpp_array_symbols( + expr: str, + species_names: List[str], + param_names: List[str], + expr_syms: List[sp.Symbol], + obs_syms: List[sp.Symbol], + rate_syms: List[sp.Symbol], +) -> str: + def replace(match: re.Match[str]) -> str: + array_name = match.group(1) + idx = int(match.group(2)) + if array_name == "species": + return species_names[idx] if idx < len(species_names) else f"s{idx}" + if array_name == "parameters": + return param_names[idx] if idx < len(param_names) else f"p{idx}" + if array_name == "expressions": + return expr_syms[idx].name if idx < len(expr_syms) else f"e{idx}" + if array_name == "observables": + return obs_syms[idx].name if idx < len(obs_syms) else f"o{idx}" + if array_name == "ratelaws": + return rate_syms[idx].name if idx < len(rate_syms) else f"r{idx}" + return match.group(0) + + return re.sub( + r"\b(species|parameters|expressions|observables|ratelaws)\s*\[\s*(\d+)\s*\]", + replace, + expr, + ) + + def _replace_parameters_brackets(expr: str, param_names: List[str]) -> str: def repl(match: re.Match[str]) -> str: idx = int(match.group(1)) diff --git a/python/bionetgen/modelapi/xmlparsers.py b/python/bionetgen/modelapi/xmlparsers.py index fc9daa2c..9fae93c8 100644 --- a/python/bionetgen/modelapi/xmlparsers.py +++ b/python/bionetgen/modelapi/xmlparsers.py @@ -437,7 +437,7 @@ def parse_xml(self, xml): def add_molecule_type_to_block(self, block, xml): name = xml["@id"] components = [] - if "ListOfComponentTypes" in xml: + if "ListOfComponentTypes" in xml and xml["ListOfComponentTypes"] is not None: comp_obj = Component() comp_dict = xml["ListOfComponentTypes"]["ComponentType"] if "@id" in comp_dict: diff --git a/python/bionetgen/sbml.py b/python/bionetgen/sbml.py index 390b8da6..fa8bc147 100644 --- a/python/bionetgen/sbml.py +++ b/python/bionetgen/sbml.py @@ -22,6 +22,34 @@ class BioNetGenError(BNGError): def _translate_sbml(sbml_path: str, atomize: bool = False, **options) -> str: + backend = str(options.pop("atomizer_backend", "modern")).lower() + if backend in {"modern", "playground"}: + try: + from bionetgen.atomizer.modern import Atomizer + + sbml_text = Path(sbml_path).read_text(encoding="utf-8-sig") + result = Atomizer( + atomize=atomize, + use_id=options.get("use_id", options.get("molecule_id", False)), + annotation=options.get("annotation", False), + quiet_mode=options.get("quiet_mode", True), + actions=options.get("actions", ""), + t_end=options.get("t_end", 10), + n_steps=options.get("n_steps", 100), + ).atomize(sbml_text) + except Exception as exc: # pragma: no cover - depends on optional deps + raise BioNetGenError(f"Modern SBML atomizer is unavailable: {exc}") from exc + if not result.success or not result.bngl: + raise BioNetGenError( + f"Modern SBML atomization failed for {sbml_path}: {result.error or 'no BNGL output'}" + ) + return result.bngl + + if backend not in {"legacy", "native"}: + raise BioNetGenError( + f"Unknown SBML atomizer backend {backend!r}; use 'modern' or 'legacy'" + ) + try: from bionetgen.atomizer import libsbml2bngl as translator except Exception as exc: # pragma: no cover - depends on optional deps diff --git a/python/bionetgen/scan.py b/python/bionetgen/scan.py index 6c4b9f67..f7282c0c 100644 --- a/python/bionetgen/scan.py +++ b/python/bionetgen/scan.py @@ -77,6 +77,15 @@ def _simulation_kwargs( pla_config: str, psa_poplevel: float, verbose: bool, + max_step: float, + steady_state: bool, + steady_state_tol: Optional[float], + stop_if: str, + sample_times, + max_sim_steps: int, + output_step_interval: int, + sparse: bool, + check_product_scale: float, ) -> dict[str, Any]: return { "method": method, @@ -89,6 +98,15 @@ def _simulation_kwargs( "pla_config": pla_config, "psa_poplevel": psa_poplevel, "verbose": verbose, + "max_step": max_step, + "steady_state": steady_state, + "steady_state_tol": steady_state_tol, + "stop_if": stop_if, + "sample_times": sample_times, + "max_sim_steps": max_sim_steps, + "output_step_interval": output_step_interval, + "sparse": sparse, + "check_product_scale": check_product_scale, } @@ -299,6 +317,15 @@ def parameter_scan( pla_config: str = "", psa_poplevel: float = 100.0, verbose: bool = False, + max_step: float = 0.0, + steady_state: bool = False, + steady_state_tol: Optional[float] = None, + stop_if: str = "", + sample_times=None, + max_sim_steps: int = 0, + output_step_interval: int = 0, + sparse: bool = False, + check_product_scale: float = 0.0, parallel: int = 0, ) -> ScanResult: model = _coerce_model(model_or_path) @@ -317,6 +344,15 @@ def parameter_scan( pla_config=pla_config, psa_poplevel=psa_poplevel, verbose=verbose, + max_step=max_step, + steady_state=steady_state, + steady_state_tol=steady_state_tol, + stop_if=stop_if, + sample_times=sample_times, + max_sim_steps=max_sim_steps, + output_step_interval=output_step_interval, + sparse=sparse, + check_product_scale=check_product_scale, ) if parallel and parallel > 1: @@ -361,6 +397,15 @@ def parameter_scan_2d( pla_config: str = "", psa_poplevel: float = 100.0, verbose: bool = False, + max_step: float = 0.0, + steady_state: bool = False, + steady_state_tol: Optional[float] = None, + stop_if: str = "", + sample_times=None, + max_sim_steps: int = 0, + output_step_interval: int = 0, + sparse: bool = False, + check_product_scale: float = 0.0, parallel: int = 0, ) -> ScanResult2D: model = _coerce_model(model_or_path) @@ -388,6 +433,15 @@ def parameter_scan_2d( pla_config=pla_config, psa_poplevel=psa_poplevel, verbose=verbose, + max_step=max_step, + steady_state=steady_state, + steady_state_tol=steady_state_tol, + stop_if=stop_if, + sample_times=sample_times, + max_sim_steps=max_sim_steps, + output_step_interval=output_step_interval, + sparse=sparse, + check_product_scale=check_product_scale, ) if parallel and parallel > 1: diff --git a/python/bionetgen/sensitivity.py b/python/bionetgen/sensitivity.py index c13c4847..87ae154e 100644 --- a/python/bionetgen/sensitivity.py +++ b/python/bionetgen/sensitivity.py @@ -96,6 +96,15 @@ def sensitivity_analysis( pla_config: str = "", psa_poplevel: float = 100.0, verbose: bool = False, + max_step: float = 0.0, + steady_state: bool = False, + steady_state_tol: Optional[float] = None, + stop_if: str = "", + sample_times=None, + max_sim_steps: int = 0, + output_step_interval: int = 0, + sparse: bool = False, + check_product_scale: float = 0.0, delta: float = 0.01, parallel: int = 0, ) -> SensitivityResult: @@ -121,6 +130,15 @@ def sensitivity_analysis( pla_config=pla_config, psa_poplevel=psa_poplevel, verbose=verbose, + max_step=max_step, + steady_state=steady_state, + steady_state_tol=steady_state_tol, + stop_if=stop_if, + sample_times=sample_times, + max_sim_steps=max_sim_steps, + output_step_interval=output_step_interval, + sparse=sparse, + check_product_scale=check_product_scale, ) if parallel and parallel > 1: diff --git a/python/bionetgen/simulator/librrsimulator.py b/python/bionetgen/simulator/librrsimulator.py index 6be05c8d..6bb60e64 100644 --- a/python/bionetgen/simulator/librrsimulator.py +++ b/python/bionetgen/simulator/librrsimulator.py @@ -42,8 +42,11 @@ def simulator(self, model): import roadrunner as rr self._simulator = rr.RoadRunner(model) - except ImportError: - print("libroadrunner is not installed!") + except ImportError as exc: + raise ImportError( + "libRoadRunner is required for sim_type='libRR'; " + "install the optional roadrunner dependency" + ) from exc @property def sbml(self): diff --git a/python/bionetgen/simulator/simulators.py b/python/bionetgen/simulator/simulators.py index 15bf13f9..2f8ed172 100644 --- a/python/bionetgen/simulator/simulators.py +++ b/python/bionetgen/simulator/simulators.py @@ -26,24 +26,25 @@ def sim_getter(model_file=None, model_str=None, sim_type="libRR"): A simulator object with an API that's supposed to be agnostic to the underlying simulator it's running. """ + if model_file is None and model_str is None: + raise ValueError("Provide model_file or model_str") + if not isinstance(sim_type, str): + raise TypeError("sim_type must be a string") + + normalized_type = sim_type.lower() + if normalized_type == "cpy": + raise ValueError("Simulator type 'cpy' is no longer supported") + if normalized_type != "librr": + raise ValueError(f"Simulator type {sim_type!r} not supported") + if model_str is not None and model_file is None: from tempfile import NamedTemporaryFile with NamedTemporaryFile("w+") as model_file_obj: model_file_obj.write(model_str) model_file = model_file_obj.name - if sim_type == "libRR": - # need to go back to beginning of the file for this to work - model_file_obj.seek(0) - return libRRSimulator(model_file=model_file) - elif sim_type == "cpy": - raise ValueError("Simulator type 'cpy' is no longer supported") - else: - print("simulator type {} not supported".format(sim_type)) - if model_file is not None: - if sim_type == "libRR": + # Need to go back to beginning for callers that inspect the file. + model_file_obj.seek(0) return libRRSimulator(model_file=model_file) - elif sim_type == "cpy": - raise ValueError("Simulator type 'cpy' is no longer supported") - else: - print("simulator type {} not supported".format(sim_type)) + + return libRRSimulator(model_file=model_file) diff --git a/scripts/prepare_matplotlib_cache.py b/scripts/prepare_matplotlib_cache.py new file mode 100644 index 00000000..97de856e --- /dev/null +++ b/scripts/prepare_matplotlib_cache.py @@ -0,0 +1,70 @@ +"""Seed Matplotlib with bundled fonts before CI imports pyplot. + +Some Ubuntu runner images expose a system font that crashes older Matplotlib +FreeType wheels while the font manager scans every installed font. The plot +tests only need Matplotlib's bundled DejaVu family, so keep the CI cache +portable and avoid the system-font scan entirely. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import matplotlib + +_DEJAVU_FONTS = ( + ("DejaVuSans.ttf", "normal", 400), + ("DejaVuSans-Bold.ttf", "normal", 700), + ("DejaVuSans-Oblique.ttf", "italic", 400), + ("DejaVuSans-BoldOblique.ttf", "italic", 700), +) + + +def _font_manager_version() -> int | str: + source_path = Path(matplotlib.__file__).with_name("font_manager.py") + source = source_path.read_text(encoding="utf-8") + match = re.search(r"__version__\s*=\s*(['\"]?)([0-9][^'\"\s]*)\1", source) + if match is None: + raise RuntimeError( + f"Could not determine Matplotlib font-cache version from {source_path}" + ) + version = match.group(2) + return int(version) if version.isdigit() else version + + +def _font_entries() -> list[dict[str, object]]: + return [ + { + "fname": f"fonts/ttf/{filename}", + "name": "DejaVu Sans", + "style": style, + "variant": "normal", + "weight": weight, + "stretch": "normal", + "size": "scalable", + "__class__": "FontEntry", + } + for filename, style, weight in _DEJAVU_FONTS + ] + + +def main() -> None: + version = _font_manager_version() + cache_path = Path(matplotlib.get_cachedir()) / f"fontlist-v{version}.json" + cache = { + "_version": version, + "_FontManager__default_weight": "normal", + "default_size": None, + "defaultFamily": {"ttf": "DejaVu Sans", "afm": "Helvetica"}, + "afmlist": [], + "ttflist": _font_entries(), + "__class__": "FontManager", + } + cache_path.write_text(json.dumps(cache), encoding="utf-8") + print(f"Seeded Matplotlib font cache: {cache_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/validate.py b/scripts/validate.py index 8d928529..bea250d1 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -7,22 +7,102 @@ Usage: python scripts/validate.py [--bng-cpp PATH] [--verbose] + python scripts/validate.py [--bng-cpp PATH] --skip-file PATH --skip-profile NAME """ import argparse +import json +import re import shutil import subprocess import sys import tempfile from pathlib import Path +from typing import Union REPO = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO)) from tests.validation.compare import compare_net, parse_net # noqa: E402 +_FILE_ARGUMENT = re.compile(r"\b(?:file|argfile)\s*=>\s*(['\"])([^'\"]+)\1") -def run_validation(bng_cpp, validate_dir, verbose=False, skip_models=None): + +def load_skip_models(skip_file: Union[Path, str], profile: str) -> list[str]: + """Load one validated reference-exclusion profile from a JSON manifest.""" + + path = Path(skip_file) + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError( + f"cannot read reference exclusion manifest {path}: {exc}" + ) from exc + + if not isinstance(manifest, dict) or manifest.get("schema_version") != 1: + raise ValueError( + f"reference exclusion manifest {path} must use schema_version 1" + ) + + profiles = manifest.get("profiles") + if not isinstance(profiles, dict): + raise ValueError(f"reference exclusion manifest {path} must contain profiles") + + models = profiles.get(profile) + if not isinstance(models, list) or any( + not isinstance(model, str) or not model.strip() for model in models + ): + raise ValueError( + f"reference exclusion profile {profile!r} must be a list of names" + ) + if len(models) != len(set(models)): + raise ValueError(f"reference exclusion profile {profile!r} contains duplicates") + + return list(models) + + +def copy_referenced_support_files( + bngl: Path, validate_dir: Path, dat_dir: Path, work_dir: Path +) -> list[Path]: + """Stage files named by model file arguments into a validation work tree. + + Validation runs in an isolated directory. Besides the BNGL input and the + explicit ``INPUT_FILES`` directory, legacy models may reference a sibling + ``.net`` or another artifact from the BNG2 reference directory. Copy the + referenced relative path, preserving subdirectories, so actions observe + the same file layout as the source model. + """ + + copied: list[Path] = [] + text = bngl.read_text(encoding="utf-8") + for match in _FILE_ARGUMENT.finditer(text): + relative = Path(match.group(2)) + if relative.is_absolute() or ".." in relative.parts: + continue + + candidates = [ + validate_dir / relative, + dat_dir / relative, + dat_dir / relative.name, + validate_dir / relative.name, + ] + source = next( + (candidate for candidate in candidates if candidate.is_file()), None + ) + if source is None: + continue + + destination = work_dir / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + copied.append(destination) + + return copied + + +def run_validation( + bng_cpp, validate_dir, verbose=False, skip_models=None, strict_references=False +): """Run bng_cpp on all .bngl files and compare against reference .net. Args: @@ -30,6 +110,7 @@ def run_validation(bng_cpp, validate_dir, verbose=False, skip_models=None): validate_dir: Path to validation directory verbose: Whether to print detailed output skip_models: List of model names (without .bngl) to skip + strict_references: Treat an unskipped missing reference .net as an error """ if skip_models is None: skip_models = [] @@ -52,9 +133,13 @@ def run_validation(bng_cpp, validate_dir, verbose=False, skip_models=None): ref_net = dat_dir / f"{model_name}.net" if not ref_net.exists(): - results["skip"] += 1 - if verbose: - details.append(f"SKIP {model_name} (no reference .net)") + if strict_references: + results["error"] += 1 + details.append(f"ERROR {model_name} (no reference .net)") + else: + results["skip"] += 1 + if verbose: + details.append(f"SKIP {model_name} (no reference .net)") continue # Run bng_cpp to generate network @@ -68,6 +153,7 @@ def run_validation(bng_cpp, validate_dir, verbose=False, skip_models=None): if input_dir.exists(): for f in input_dir.iterdir(): shutil.copy(f, Path(tmpdir) / f.name) + copy_referenced_support_files(bngl, validate_dir, dat_dir, Path(tmpdir)) try: result = subprocess.run( @@ -139,6 +225,22 @@ def main(): default="", help="Comma-separated list of model names to skip (without .bngl extension)", ) + parser.add_argument( + "--skip-file", + type=Path, + default=None, + help="JSON manifest containing named reference-exclusion profiles", + ) + parser.add_argument( + "--skip-profile", + default=None, + help="Profile to load from --skip-file", + ) + parser.add_argument( + "--strict-references", + action="store_true", + help="Treat an unskipped model without a reference .net as an error", + ) args = parser.parse_args() # Find bng_cpp @@ -171,8 +273,16 @@ def main(): print(f"ERROR: Validation directory not found: {validate_dir}") sys.exit(1) + if bool(args.skip_file) != bool(args.skip_profile): + parser.error("--skip-file and --skip-profile must be provided together") + # Parse skip list skip_models = [m.strip() for m in args.skip.split(",") if m.strip()] + if args.skip_file: + try: + skip_models.extend(load_skip_models(args.skip_file, args.skip_profile)) + except ValueError as exc: + parser.error(str(exc)) print(f"BNG C++: {bng_cpp}") print(f"Validation: {validate_dir}") @@ -182,7 +292,11 @@ def main(): print() results, details = run_validation( - bng_cpp, validate_dir, verbose=args.verbose, skip_models=skip_models + bng_cpp, + validate_dir, + verbose=args.verbose, + skip_models=skip_models, + strict_references=args.strict_references, ) # Print details @@ -198,7 +312,12 @@ def main(): print(f" PASS: {results['pass']}") print(f" FAIL: {results['fail']}") print(f" ERROR: {results['error']}") - print(f" SKIP: {results['skip']} (no reference .net)") + skip_note = ( + "explicit exclusions only" + if args.strict_references + else "explicit exclusions or no reference .net" + ) + print(f" SKIP: {results['skip']} ({skip_note})") print("=" * 60) if results["fail"] > 0 or results["error"] > 0: diff --git a/tests/cpp/CMakeLists.txt b/tests/cpp/CMakeLists.txt index ec07ea76..1711d68d 100644 --- a/tests/cpp/CMakeLists.txt +++ b/tests/cpp/CMakeLists.txt @@ -1,5 +1,20 @@ include(Catch) +add_test( + NAME bng3_release_lto_contract + COMMAND ${CMAKE_COMMAND} + -DCONTRACT_PATH=${CMAKE_BINARY_DIR}/bng3_lto_contract.txt + -P ${CMAKE_CURRENT_SOURCE_DIR}/test_release_lto.cmake +) + +add_test( + NAME bng_cpp_action_errors + COMMAND ${CMAKE_COMMAND} + -DBNG_CPP_PATH=$ + -DMODEL_PATH=${CMAKE_SOURCE_DIR}/tests/fixtures/issue_328_missing_net.bngl + -P ${CMAKE_CURRENT_SOURCE_DIR}/test_bng_cpp_action_errors.cmake +) + # localfunc_test add_executable(localfunc_test localfunc_test.cpp) target_include_directories(localfunc_test PRIVATE @@ -39,6 +54,25 @@ target_link_libraries(test_pla_simulator PRIVATE ) catch_discover_tests(test_pla_simulator) +# test_ode_options +add_executable(test_ode_options test_ode_options.cpp) +target_include_directories(test_ode_options PRIVATE + ${CMAKE_SOURCE_DIR}/cpp + ${CMAKE_SOURCE_DIR}/cpp/parser + ${CMAKE_SOURCE_DIR}/cpp/parser/generated + ${antlr4_runtime_SOURCE_DIR}/runtime/Cpp/runtime/src + ${sundials_SOURCE_DIR}/include + ${sundials_BINARY_DIR}/include +) +target_link_libraries(test_ode_options PRIVATE + Catch2::Catch2WithMain + Catch2::Catch2 + bng_engine + bng_ast + antlr4_static +) +catch_discover_tests(test_ode_options) + # test_hybrid_model_generator add_executable(test_hybrid_model_generator test_hybrid_model_generator.cpp) target_link_libraries(test_hybrid_model_generator PRIVATE @@ -138,3 +172,30 @@ target_link_libraries(test_nfsim_ast_adapter PRIVATE # GNU ld resolves the static-archive cycle on this test target. bng_parser) catch_discover_tests(test_nfsim_ast_adapter) + +# test_nfsim_reactant_tree +add_executable(test_nfsim_reactant_tree test_nfsim_reactant_tree.cpp) +target_include_directories(test_nfsim_reactant_tree PRIVATE + ${CMAKE_SOURCE_DIR}/cpp + ${CMAKE_SOURCE_DIR}/cpp/nfsim + ${CMAKE_SOURCE_DIR}/cpp/nfsim/NFcore + ${CMAKE_SOURCE_DIR}/cpp/nfsim/NFinput + ${exprtk_SOURCE_DIR} +) +target_link_libraries(test_nfsim_reactant_tree PRIVATE + Catch2::Catch2WithMain Catch2::Catch2 + bionetgen_core + bng_parser) +catch_discover_tests(test_nfsim_reactant_tree) + +# test_sbml_reader +add_executable(test_sbml_reader test_sbml_reader.cpp) +target_include_directories(test_sbml_reader PRIVATE + ${CMAKE_SOURCE_DIR}/cpp + ${CMAKE_SOURCE_DIR}/cpp/nfsim + ${CMAKE_SOURCE_DIR}/cpp/nfsim/NFinput + ${CMAKE_SOURCE_DIR}/cpp/nfsim/NFinput/TinyXML +) +target_link_libraries(test_sbml_reader PRIVATE + Catch2::Catch2WithMain Catch2::Catch2 bionetgen_core) +catch_discover_tests(test_sbml_reader) diff --git a/tests/cpp/lto_contract.in b/tests/cpp/lto_contract.in new file mode 100644 index 00000000..1e9d8bc0 --- /dev/null +++ b/tests/cpp/lto_contract.in @@ -0,0 +1,6 @@ +NFSIM_ENABLE_LTO=@NFSIM_ENABLE_LTO@ +NFSIM_IPO_SUPPORTED=@NFSIM_IPO_SUPPORTED@ +nfsim_core_RELEASE_IPO=@BNG3_NFSIM_CORE_IPO@ +bng_cpp_RELEASE_IPO=@BNG3_BNG_CPP_IPO@ +_bionetgen_cpp_RELEASE_IPO=@BNG3_PYTHON_IPO@ +NFsim_RELEASE_IPO=@BNG3_NFSIM_CLI_IPO@ diff --git a/tests/cpp/test_bng_cpp_action_errors.cmake b/tests/cpp/test_bng_cpp_action_errors.cmake new file mode 100644 index 00000000..43ddc92a --- /dev/null +++ b/tests/cpp/test_bng_cpp_action_errors.cmake @@ -0,0 +1,17 @@ +execute_process( + COMMAND "${BNG_CPP_PATH}" "${MODEL_PATH}" + RESULT_VARIABLE result + OUTPUT_VARIABLE stdout_text + ERROR_VARIABLE stderr_text +) + +if(NOT result EQUAL 1) + message(FATAL_ERROR + "bng_cpp should report action errors with exit code 1, got '${result}'.\n" + "stdout:\n${stdout_text}\nstderr:\n${stderr_text}") +endif() + +if(NOT stderr_text MATCHES "action execution failed") + message(FATAL_ERROR + "bng_cpp did not report the action failure on stderr:\n${stderr_text}") +endif() diff --git a/tests/cpp/test_network_generator.cpp b/tests/cpp/test_network_generator.cpp index 4b899d20..b9990d6f 100644 --- a/tests/cpp/test_network_generator.cpp +++ b/tests/cpp/test_network_generator.cpp @@ -1,11 +1,21 @@ #include +#include +#include +#include +#include +#include + +#include "../../cpp/ast/MacroBNGModel.hpp" #include "../../cpp/engine/NetworkGenerator.hpp" // Include implementation to exercise evaluateRateString in anonymous namespace. #include "../../cpp/engine/NetworkGenerator.cpp" +#include "../../cpp/ast/SpeciesList.hpp" using namespace bng::engine; TEST_CASE("parseBooleanLike behaves correctly", "[NetworkGenerator]") { + // Source-derived from akutuva21/bionetgen commit f30898b6: preserve the + // accepted boolean spellings while avoiding a temporary lowercased copy. // positive cases REQUIRE(parseBooleanLike("1") == true); REQUIRE(parseBooleanLike("true") == true); @@ -28,3 +38,121 @@ TEST_CASE("parseBooleanLike behaves correctly", "[NetworkGenerator]") { REQUIRE(parseBooleanLike("asdf") == false); REQUIRE(parseBooleanLike("2") == false); } + +TEST_CASE("SpeciesList preserves compartment-aware deduplication", "[SpeciesList]") { + // Source-derived from akutuva21/bionetgen commit 5291159d: exact-key + // fast paths must not merge per-molecule compartments under one species + // compartment. + BNGcore::EntityType moleculeType("M", BNGcore::ENTITY_NODE_TYPE, BNGcore::NULL_STATE_TYPE); + BNGcore::EntityType componentType("site", BNGcore::COMPONENT_NODE_TYPE, BNGcore::NULL_STATE_TYPE); + + auto makeSpecies = [&](const std::string& moleculeCompartment) { + BNGcore::PatternGraph graph; + BNGcore::Node molecule(moleculeType); + BNGcore::Node component(componentType); + auto* moleculeNode = graph.add_node(molecule); + auto* componentNode = graph.add_node(component); + moleculeNode->set_compartment(moleculeCompartment); + graph.add_edge(moleculeNode, componentNode); + return bng::ast::Species( + bng::ast::SpeciesGraph(std::move(graph), "NM"), + 0.0, + false, + "NM"); + }; + + bng::ast::Species cytosolic = makeSpecies("CP"); + bng::ast::Species nuclear = makeSpecies("NU"); + bng::ast::SpeciesList list; + + const auto first = list.add(cytosolic); + const auto second = list.add(nuclear); + const auto duplicate = list.add(cytosolic); + + REQUIRE(first.second); + REQUIRE(second.second); + REQUIRE_FALSE(duplicate.second); + REQUIRE(duplicate.first == first.first); + + const bng::ast::Species probe = makeSpecies("CP"); + const auto exact = list.findExact(probe); + + REQUIRE(exact.has_value()); + REQUIRE(exact.value() == first.first); + REQUIRE_FALSE(probe.getSpeciesGraph().getGraph().is_canonical()); + REQUIRE_FALSE(list.findExact(makeSpecies("MITO")).has_value()); + + // Source-derived from akutuva21/bionetgen commit 70acc9e2: the exact key + // can be returned by lookup and reused by a subsequent keyed insertion. + std::string exactKey; + const auto missing = list.findExact(makeSpecies("MITO"), exactKey); + REQUIRE_FALSE(missing.has_value()); + REQUIRE_FALSE(exactKey.empty()); + const auto keyed = list.addWithExactKey(makeSpecies("MITO"), std::move(exactKey)); + REQUIRE(keyed.second); + REQUIRE(list.size() == 3); +} + +TEST_CASE("MacroBNGModel num_site preserves Perl dependency semantics", "[MacroBNGModel]") { + // Source-derived from legacy/perl/Perl2/MacroBNGModel.pm:num_site: + // duplicate sites are numbered from the molecule-site inventory, while + // the activated site and its remaining dependencies are returned in the + // original order. + bng::ast::MacroBNGModel macro; + std::map siteCounts{{"Lig:l", 2}}; + std::vector dependencies; + + const auto active = macro.num_site("Lig(l,l)", "Lig(l!1,l)", siteCounts, dependencies); + + REQUIRE(active == "l"); + REQUIRE(dependencies == std::vector{"l", "l:2"}); + + dependencies.clear(); + REQUIRE(macro.num_site("Lig(l,l)", "Lig(l,l)", siteCounts, dependencies) == "%"); + REQUIRE(dependencies == std::vector{"l"}); +} + +TEST_CASE("MacroBNGModel pre_macr runs the species and rule transforms", "[MacroBNGModel]") { + // Source-derived from MacroBNGModel.pm::pre_macr: rule dependencies are + // prepared before trans_rec/trans_specie write the combined macro BNGL. + const auto workdir = std::filesystem::temp_directory_path() / + ("bng3_macro_model_" + + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + std::filesystem::create_directories(workdir); + const auto original = std::filesystem::current_path(); + struct WorkingDirectoryGuard { + std::filesystem::path original; + std::filesystem::path temporary; + ~WorkingDirectoryGuard() { + std::error_code error; + std::filesystem::current_path(original, error); + std::filesystem::remove_all(temporary, error); + } + } guard{original, workdir}; + std::filesystem::current_path(workdir); + + std::ofstream input("macro_input.bngl"); + input << "begin parameters\n" + << " k 1\n" + << "end parameters\n" + << "begin species\n" + << " Lig(l,l) 10\n" + << "end species\n" + << "begin reaction_rules\n" + << " Lig(l,l) -> Lig(l!1,l) k\n" + << "end reaction_rules\n" + << "end model\n"; + input.close(); + + bng::ast::MacroBNGModel macro; + REQUIRE(macro.pre_macr("macro_input") == ""); + + std::ifstream output("macr_macro_input.bngl"); + std::stringstream contents; + contents << output.rdbuf(); + const auto generated = contents.str(); + + REQUIRE(generated.find("begin species") != std::string::npos); + REQUIRE(generated.find("Lig_l(l) 10") != std::string::npos); + REQUIRE(generated.find("begin reaction_rules") != std::string::npos); +} diff --git a/tests/cpp/test_nfsim_ast_adapter.cpp b/tests/cpp/test_nfsim_ast_adapter.cpp index 64f8524e..2073c527 100644 --- a/tests/cpp/test_nfsim_ast_adapter.cpp +++ b/tests/cpp/test_nfsim_ast_adapter.cpp @@ -1,17 +1,22 @@ #include #include +#include #include +#include #include #include #include #include #include +#include #include "NFinput_fromAst.hh" #include "NFinput.hh" #include "NFcore.hh" #include "NFcore/energyPattern.hh" +#include "NFcore/reactionSelector/reactionSelector.hh" +#include "NFreactions/reactions/reaction.hh" #include "compartment.hh" #include "NFfunction/NFfunction.hh" #include "ast/Function.hpp" @@ -25,6 +30,7 @@ #include "BNGParser.h" #include #include "io/XmlWriter.hpp" +#include "io/BnglWriter.hpp" #include "parser/BNGAstVisitor.hpp" namespace { @@ -41,6 +47,53 @@ BNGcore::PatternGraph parseSpeciesGraph(const std::string& text, bng::ast::Model } // namespace +TEST_CASE("NFsim compact reaction membership IDs stay sorted and unique") { + // Source-derived from NFsim ad4b56a: reaction membership is commonly + // empty or contains one mapping, so the compact representation must retain + // ordered set semantics without duplicate IDs. + NFcore::MappingIdSet mappingIds; + CHECK(mappingIds.empty()); + CHECK(mappingIds.insert(9).second); + CHECK(mappingIds.insert(3).second); + CHECK_FALSE(mappingIds.insert(9).second); + REQUIRE(mappingIds.size() == 2); + auto mappingIdsIt = mappingIds.begin(); + CHECK(*mappingIdsIt == 3); + ++mappingIdsIt; + CHECK(*mappingIdsIt == 9); + CHECK(mappingIds.erase(3) == 1); + CHECK(mappingIds.erase(3) == 0); + REQUIRE(mappingIds.size() == 1); + CHECK(*mappingIds.begin() == 9); + mappingIds.clear(); + CHECK(mappingIds.empty()); + + NFcore::MappingIdSet overflowIds; + for (int id : {7, 3, 11, 1, 9}) + CHECK(overflowIds.insert(id).second); + CHECK_FALSE(overflowIds.insert(7).second); + REQUIRE(overflowIds.size() == 5); + std::vector observed; + for (auto it = overflowIds.begin(); it != overflowIds.end(); ++it) + observed.push_back(*it); + CHECK(observed == std::vector {1, 3, 7, 9, 11}); + + NFcore::MappingIdSet copiedIds(overflowIds); + NFcore::MappingIdSet assignedIds; + assignedIds = overflowIds; + CHECK(copiedIds.size() == overflowIds.size()); + CHECK(assignedIds.size() == overflowIds.size()); + CHECK(copiedIds.erase(1) == 1); + CHECK(copiedIds.erase(7) == 1); + CHECK(copiedIds.erase(11) == 1); + CHECK(copiedIds.erase(11) == 0); + observed.clear(); + for (auto it = copiedIds.begin(); it != copiedIds.end(); ++it) + observed.push_back(*it); + CHECK(observed == std::vector {3, 9}); + CHECK(assignedIds.size() == 5); +} + TEST_CASE("BNGL parser preserves inline and file TFUN metadata") { const auto linear = bng::parser::parseExpression( "TFUN([0, 1, 2], [0, 10, 20], time)"); @@ -95,6 +148,179 @@ end seed species REQUIRE(model->getSeedSpecies().size() == 1); } +TEST_CASE("BNGL infers numeric molecule states from all patterns") { + // Native BNG2/NFsim accepts models without a molecule-types block and + // derives the complete integer range from every later pattern. This is + // distilled from nfsim/test/testSuite/t_dor2.bngl: the seed introduces + // m~1, while observables and rules reference m~0, m~2, and m~3. + auto model = bng::parser::parseModel(R"( +begin parameters + k 1 +end parameters +begin seed species + A(m~1) 1 +end seed species +begin observables + Molecules A0 A(m~0) + Molecules A2 A(m~2) + Molecules A3 A(m~3) +end observables +begin reaction rules + A(m~1) -> A(m~3) k +end reaction rules +)"); + + REQUIRE(model != nullptr); + REQUIRE(model->getMoleculeTypes().size() == 1); + REQUIRE(model->getMoleculeTypes().front().getComponents().size() == 1); + CHECK(model->getMoleculeTypes().front().getComponents().front().allowedStates == + std::vector {"1", "0", "2", "3"}); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + auto* moleculeType = system->getMoleculeTypeByName("A"); + REQUIRE(moleculeType != nullptr); + CHECK(moleculeType->isIntegerComponent(0)); + CHECK(moleculeType->getStateValueFromName(0, "0") == 0); + CHECK(moleculeType->getStateValueFromName(0, "3") == 3); + delete system; +} + +TEST_CASE("BNGL inferred integer states ignore wildcard and transition sentinels") { + // Source-derived from nfsim/test/AN_chemotaxis/an2.bngl: an inferred + // integer component is matched with m~?, then advanced with m~PLUS or + // m~MINUS. NFinput::initMoleculeTypes treats those three tokens as + // pattern/transition syntax, not as string-valued molecule states. + auto model = bng::parser::parseModel(R"( +begin seed species + A(m~3) 1 +end seed species +begin observables + Molecules A0 A(m~0) + Molecules A8 A(m~8) +end observables +begin reaction rules + A(m~?) -> A(m~PLUS) 1 +end reaction rules +)"); + + REQUIRE(model != nullptr); + REQUIRE(model->getMoleculeTypes().size() == 1); + REQUIRE(model->getMoleculeTypes().front().getComponents().size() == 1); + const auto& component = model->getMoleculeTypes().front().getComponents().front(); + CHECK(component.allowedStates == + std::vector {"3", "0", "8", "?", "PLUS"}); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + auto* moleculeType = system->getMoleculeTypeByName("A"); + REQUIRE(moleculeType != nullptr); + CHECK(moleculeType->isIntegerComponent(0)); + CHECK(moleculeType->getPossibleCompStates().at(0) == + std::vector {"0", "1", "2", "3", "4", "5", "6", "7", "8"}); + CHECK(moleculeType->getStateValueFromName(0, "3") == 3); + CHECK(moleculeType->getStateValueFromName(0, "8") == 8); + delete system; + + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("") == std::string::npos); + + suggestedTraversalLimit = 0; + auto* xmlSystem = NFinput::initializeFromModel( + static_cast(model.get()), false, 100, false, suggestedTraversalLimit); + REQUIRE(xmlSystem != nullptr); + auto* xmlMoleculeType = xmlSystem->getMoleculeTypeByName("A"); + REQUIRE(xmlMoleculeType != nullptr); + CHECK(xmlMoleculeType->isIntegerComponent(0)); + CHECK(xmlMoleculeType->getPossibleCompStates().at(0) == + std::vector {"0", "1", "2", "3", "4", "5", "6", "7", "8"}); + delete xmlSystem; +} + +TEST_CASE("BNGL inferred type ordering matches BNG2 canonical registration") { + // Source-derived from BNG2 MoleculeTypesList.pm::toXML/writeBNGL and + // SpeciesGraph.pm::labelQuasi: inferred molecule types are registered and + // serialized by lexical molecule name, while their component definitions + // follow the canonical component order rather than source spelling order. + auto model = bng::parser::parseModel(R"( +begin seed species + Z(b,a) 1 + A(y,x) 1 +end seed species +)"); + + REQUIRE(model != nullptr); + REQUIRE(model->getMoleculeTypes().size() == 2); + + const auto xml = bng::io::XmlWriter::write(*model); + const auto aType = xml.find(""); + const auto zType = xml.find(""); + REQUIRE(aType != std::string::npos); + REQUIRE(zType != std::string::npos); + CHECK(aType < zType); + CHECK(xml.find("getNumOfMoleculeTypes() == 2); + CHECK(system->getMoleculeType(0)->getName() == "A"); + CHECK(system->getMoleculeType(1)->getName() == "Z"); + CHECK(system->getMoleculeTypeByName("A")->getComponentName(0) == "x"); + CHECK(system->getMoleculeTypeByName("A")->getComponentName(1) == "y"); + CHECK(system->getMoleculeTypeByName("Z")->getComponentName(0) == "a"); + CHECK(system->getMoleculeTypeByName("Z")->getComponentName(1) == "b"); + delete system; +} + +TEST_CASE("BNGL parser keeps the historical NFsim t4 gap explicit") { + // Source-derived from nfsim/test/testSuite/t4.bngl. The historical + // preliminary fixture uses an observable state counter (sum(m)), dollar + // molecule tags ($1), and tagged local-function calls. These remain + // outside BNG3's supported direct-parser contract until an active oracle, + // canonical-AST representation, and runtime semantics are established. + const auto source = R"( +begin parameters + kr 7 + kb 20 + ReceptorDimerCount 4000 +end parameters +begin seed species + ReceptorDimer(m~3) ReceptorDimerCount +end seed species +begin observables + Molecules R0 ReceptorDimer(m~0) + Molecules MethSum ReceptorDimer(sum(m)) +end observables +begin functions + openSites($1) = kr*(8-MethSum($1)) + closedSites($1) = kb*MethSum($1) +end functions +begin reaction rules + ReceptorDimer$1(m~^[8]) -> ReceptorDimer$1(m~++) openSites($1) + ReceptorDimer$1(m~^[0]) -> ReceptorDimer$1(m~--) closedSites($1) +end reaction rules +)"; + + REQUIRE_THROWS_WITH(bng::parser::parseModel(source), + "Cannot build model from source with syntax errors"); +} + TEST_CASE("XML writer preserves the first explicit bond") { auto model = bng::parser::parseModel(R"( begin molecule types @@ -113,6 +339,316 @@ end seed species CHECK(xml.find("site2=") != std::string::npos); } +TEST_CASE("NFsim XML bridge preserves energy patterns") { + // Source-derived from BNG2 BNGOutput.pm::writeXML and + // EnergyPattern.pm::toXML: energy patterns must retain the canonical + // pattern string plus the nested molecule/component/bond graph consumed + // by NFsim's XML energy parser. + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Ea 0.0 + Gbind 1.0 + Gfree 0.0 + RT 1.0 +end parameters +begin molecule types + A(b) + B(a) +end molecule types +begin seed species + A(b) 1 + B(a) 1 +end seed species +begin energy patterns + B(a!1).A(b!1) Gbind + A(b) Gfree +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,Ea) +end reaction rules +)"); + + REQUIRE(model != nullptr); + REQUIRE(model->getEnergyPatterns().size() == 2); + const auto& energyPattern = model->getEnergyPatterns().front(); + CHECK(energyPattern.getGraph().toString() == "A(b!1).B(a!1)"); + + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("") != std::string::npos); + CHECK(xml.find( + "") != + std::string::npos); + CHECK(xml.find("") != std::string::npos); + CHECK(xml.find("") != + std::string::npos); + CHECK(xml.find("") != + std::string::npos); + CHECK(xml.find("") != + std::string::npos); + CHECK(xml.find( + "") != + std::string::npos); + CHECK(xml.find("") != + std::string::npos); + + const auto token = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto xmlPath = std::filesystem::temp_directory_path() / + ("bng3-energy-pattern-" + std::to_string(token) + ".xml"); + std::ofstream xmlFile(xmlPath); + REQUIRE(xmlFile.good()); + xmlFile << xml; + xmlFile.close(); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::initializeFromXML( + xmlPath.string(), false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getEnergyFunction() != nullptr); + CHECK(system->getEnergyFunction()->getNumPatterns() == 2); + CHECK(system->getAllReactions().size() == 2); + delete system; + + std::error_code error; + std::filesystem::remove(xmlPath, error); +} + +TEST_CASE("NFsim XML bridge expands Arrhenius state changes") { + // Source-derived from BNG2 RxnRule.pm::toXML: a reversible Arrhenius + // state-change rule is emitted once because the same rate law supplies + // both directions. NFsim must then use the EnergyFunction expansion + // rather than silently dropping the rule. + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Ea 0.0 + Gup 1.0 + Gdn 0.0 + RT 1.0 +end parameters +begin molecule types + M(sp~up~dn) +end molecule types +begin seed species + M(sp~dn) 1 +end seed species +begin energy patterns + M(sp~up) Gup + M(sp~dn) Gdn +end energy patterns +begin reaction rules + M(sp~dn) <-> M(sp~up) Arrhenius(phi,Ea) + M(sp~up) -> M(sp~dn) 0.25 +end reaction rules +)"); + + REQUIRE(model != nullptr); + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("") != + std::string::npos); + CHECK(xml.find("energyIncludeReverse=\"1\"") != std::string::npos); + CHECK(xml.find("") != + std::string::npos); + + int directTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, directTraversalLimit); + REQUIRE(direct != nullptr); + REQUIRE(direct->getEnergyFunction() != nullptr); + CHECK(direct->getAllReactions().size() == 3); + delete direct; + + const auto token = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto xmlPath = std::filesystem::temp_directory_path() / + ("bng3-energy-state-change-" + std::to_string(token) + ".xml"); + std::ofstream xmlFile(xmlPath); + REQUIRE(xmlFile.good()); + xmlFile << xml; + xmlFile.close(); + + int xmlTraversalLimit = 0; + auto* xmlSystem = NFinput::initializeFromXML( + xmlPath.string(), false, 100, false, xmlTraversalLimit); + REQUIRE(xmlSystem != nullptr); + REQUIRE(xmlSystem->getEnergyFunction() != nullptr); + CHECK(xmlSystem->getEnergyFunction()->getNumPatterns() == 2); + CHECK(xmlSystem->getAllReactions().size() == 3); + delete xmlSystem; + + std::error_code error; + std::filesystem::remove(xmlPath, error); +} + +TEST_CASE("NFsim XML bridge preserves one-way Arrhenius direction") { + // Source-derived from BNG2's one-way reaction contract: a one-way + // Arrhenius state change must not acquire an implicit reverse reaction. + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.25 + Ea 0.0 + Gup 1.0 + Gdn 0.0 + RT 1.0 +end parameters +begin molecule types + M(sp~up~dn) +end molecule types +begin seed species + M(sp~dn) 1 +end seed species +begin energy patterns + M(sp~up) Gup + M(sp~dn) Gdn +end energy patterns +begin reaction rules + M(sp~dn) -> M(sp~up) Arrhenius(phi,Ea) +end reaction rules +)"); + + REQUIRE(model != nullptr); + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("energyIncludeReverse=\"0\"") != std::string::npos); + + int directTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, directTraversalLimit); + REQUIRE(direct != nullptr); + REQUIRE(direct->getEnergyFunction() != nullptr); + CHECK(direct->getAllReactions().size() == 1); + delete direct; + + const auto token = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto xmlPath = std::filesystem::temp_directory_path() / + ("bng3-one-way-energy-state-" + std::to_string(token) + ".xml"); + std::ofstream xmlFile(xmlPath); + REQUIRE(xmlFile.good()); + xmlFile << xml; + xmlFile.close(); + + int xmlTraversalLimit = 0; + auto* xmlSystem = NFinput::initializeFromXML( + xmlPath.string(), false, 100, false, xmlTraversalLimit); + REQUIRE(xmlSystem != nullptr); + REQUIRE(xmlSystem->getEnergyFunction() != nullptr); + CHECK(xmlSystem->getEnergyFunction()->getNumPatterns() == 2); + CHECK(xmlSystem->getAllReactions().size() == 1); + delete xmlSystem; + + std::error_code error; + std::filesystem::remove(xmlPath, error); +} + +TEST_CASE("XML writer canonicalizes seed species like BNG2") { + // BNG2 parses and quasi-canonicalizes seed SpeciesGraphs before writeXML. + // The graph serializer sorts molecules/components and renumbers bonds; + // emitting the raw seed text changes NFsim mapping order for asymmetric + // complexes and can change seeded trajectories. + auto model = bng::parser::parseModel(R"( +begin molecule types + A(b,a) + B(x) +end molecule types +begin seed species + A(b!1,a).B(x!1) 1 +end seed species +)"); + + REQUIRE(model != nullptr); + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find( + "name=\"A(a,b!1).B(x!1)\"") != std::string::npos); + CHECK(xml.find( + "") != + std::string::npos); +} + +TEST_CASE("NFsim AST adapter canonicalizes seed molecule order like BNG2") { + // Source-derived from BNG2 SpeciesGraph.pm::labelQuasi/cmp_molecule: + // seed molecules with the same type are ordered by their sorted + // components, with a more-bound component preceding a free component. + // This order is observable to NFsim's seeded mapping selectors. + auto model = bng::parser::parseModel(R"( +begin molecule types + A(a,b) + B(x,y) +end molecule types +begin seed species + A(a,b!1).B(x!1,y).A(a!2,b).B(x!2,y) 1 +end seed species +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(direct != nullptr); + auto* directA = direct->getMoleculeTypeByName("A"); + REQUIRE(directA != nullptr); + REQUIRE(directA->getMoleculeCount() == 2); + const auto directAIndex = directA->getCompIndexFromName("a"); + const auto directBIndex = directA->getCompIndexFromName("b"); + CHECK(directA->getMolecule(0)->isBindingSiteBonded(directAIndex)); + CHECK_FALSE(directA->getMolecule(0)->isBindingSiteBonded(directBIndex)); + delete direct; + + suggestedTraversalLimit = 0; + auto* xml = NFinput::initializeFromModel( + static_cast(model.get()), false, 100, false, suggestedTraversalLimit); + REQUIRE(xml != nullptr); + auto* xmlA = xml->getMoleculeTypeByName("A"); + REQUIRE(xmlA != nullptr); + REQUIRE(xmlA->getMoleculeCount() == 2); + const auto xmlAIndex = xmlA->getCompIndexFromName("a"); + const auto xmlBIndex = xmlA->getCompIndexFromName("b"); + CHECK(xmlA->getMolecule(0)->isBindingSiteBonded(xmlAIndex)); + CHECK_FALSE(xmlA->getMolecule(0)->isBindingSiteBonded(xmlBIndex)); + delete xml; +} + +TEST_CASE("NFsim AST adapter allocates repeated seed molecules like XML") { + // Source-derived from NFinput::initStartSpecies: XML creates all copies + // of each molecule position before moving to the next position. This is + // observable when one seed species contains repeated molecules of one type. + auto model = bng::parser::parseModel(R"( +begin molecule types + A(b) +end molecule types +begin seed species + A(b!1).A(b!1) 3 +end seed species +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(direct != nullptr); + auto* directA = direct->getMoleculeTypeByName("A"); + REQUIRE(directA != nullptr); + REQUIRE(directA->getMoleculeCount() == 6); + + suggestedTraversalLimit = 0; + auto* xml = NFinput::initializeFromModel( + static_cast(model.get()), false, 100, false, suggestedTraversalLimit); + REQUIRE(xml != nullptr); + auto* xmlA = xml->getMoleculeTypeByName("A"); + REQUIRE(xmlA != nullptr); + REQUIRE(xmlA->getMoleculeCount() == 6); + + const std::vector expectedPartners {3, 4, 5, 0, 1, 2}; + for (int moleculeIndex = 0; moleculeIndex < 6; ++moleculeIndex) { + CHECK(directA->getMolecule(moleculeIndex)->getBondedMolecule(0)->getMolListId() == + expectedPartners[moleculeIndex]); + CHECK(xmlA->getMolecule(moleculeIndex)->getBondedMolecule(0)->getMolListId() == + expectedPartners[moleculeIndex]); + } + + delete direct; + delete xml; +} + TEST_CASE("NFsim AST adapter maps an inline time-backed TFUN directly") { auto model = bng::parser::parseModel(R"( begin parameters @@ -469,16 +1005,119 @@ TEST_CASE("NFsim AST adapter builds a direct no-rule system") { delete system; } -TEST_CASE("NFsim AST adapter counts a connected observable pattern once") { - bng::ast::Model model; - model.setModelName("connected-observable"); - model.addMoleculeType(bng::ast::MoleculeType("A", {{"x", {}}})); - model.addMoleculeType(bng::ast::MoleculeType("B", {{"y", {}}})); - model.addSeedSpecies(bng::ast::SeedSpecies( - "A(x!1).B(y!1)", bng::ast::Expression::number(1.0), false, {}, - parseSpeciesGraph("A(x!1).B(y!1)", model))); - model.addObservable( - bng::ast::Observable("AB", "Molecules", {"A(x!1).B(y!1)"})); +TEST_CASE("NFsim AST adapter accepts legacy single-ended seed bond metadata") { + // BNG2's XML species reader preserves a numeric seed bond's + // numberOfBonds="1" metadata even when the source bond label has no + // second endpoint. It creates the molecule without an actual runtime + // bond; this is the compatibility behavior used by the RNA NFsim + // fixture, so the direct AST path must not reject that seed. + auto model = bng::parser::parseModel(R"( +begin molecule types + DNA(three) +end molecule types +begin seed species + DNA(three!1) 1 +end seed species +begin observables + Molecules DNA_total DNA() +end observables +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getNumOfMolecules() == 1); + auto* moleculeType = system->getMoleculeTypeByName("DNA"); + REQUIRE(moleculeType != nullptr); + const auto componentIndex = moleculeType->getCompIndexFromName("three"); + CHECK(moleculeType->getMolecule(0)->isBindingSiteOpen(componentIndex)); + delete system; +} + +TEST_CASE("NFsim AST adapter consumes Null discard seeds") { + auto model = bng::parser::parseModel(R"( +begin molecule types + A() + Null() +end molecule types +begin seed species + A() 1 + $Null() 0 +end seed species +begin observables + Molecules A_total A() +end observables +begin reaction rules + A() -> Null() 1 +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + CHECK(system->getNumOfMoleculeTypes() == 1); + CHECK(system->getNumOfMolecules() == 1); + CHECK(system->getAllReactions().size() == 1); + delete system; +} + +TEST_CASE("NFsim AST adapter creates products with symmetric components") { + auto model = bng::parser::parseModel(R"( +begin parameters + k_bind 2 + k_unbind 1 +end parameters +begin molecule types + D(x,x) + C(n~0~1~2~3) +end molecule types +begin seed species + D(x,x) 2 + C(n~3) 0 +end seed species +begin observables + Species D1 D(x,x) + Species D2 D(x,x!0).D(x!0,x) + Species D3 D(x,x!0).D(x!0,x!1).D(x!1,x) + Species D3c D(x!2,x!0).D(x!0,x!1).D(x!1,x!2) +end observables +begin reaction rules + C(n~3) -> D(x,x) 1 + D(x,x) + D(x,x) <-> D(x!0,x).D(x!0,x) k_bind, k_unbind +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(direct != nullptr); + REQUIRE(direct->getAllReactions().size() > 1); + const auto directReactionCount = direct->getAllReactions().size(); + delete direct; + + suggestedTraversalLimit = 0; + auto* xml = NFinput::initializeFromModel( + static_cast(model.get()), false, 100, false, suggestedTraversalLimit); + REQUIRE(xml != nullptr); + CHECK(xml->getAllReactions().size() == directReactionCount); + delete xml; +} + +TEST_CASE("NFsim AST adapter counts a connected observable pattern once") { + bng::ast::Model model; + model.setModelName("connected-observable"); + model.addMoleculeType(bng::ast::MoleculeType("A", {{"x", {}}})); + model.addMoleculeType(bng::ast::MoleculeType("B", {{"y", {}}})); + model.addSeedSpecies(bng::ast::SeedSpecies( + "A(x!1).B(y!1)", bng::ast::Expression::number(1.0), false, {}, + parseSpeciesGraph("A(x!1).B(y!1)", model))); + model.addObservable( + bng::ast::Observable("AB", "Molecules", {"A(x!1).B(y!1)"})); int suggestedTraversalLimit = 0; auto* system = NFinput::buildSystemFromAst(model, false, 100, false, @@ -491,6 +1130,155 @@ TEST_CASE("NFsim AST adapter counts a connected observable pattern once") { delete system; } +TEST_CASE("NFsim AST adapter counts a pure context homodimer once per complex") { + // Source-derived from akutuva21/nfsim commit 9b2fff8: a reactant that the + // rule never transforms contributes one reaction instance per matching + // complex, not one per matching molecule in that complex. The two C + // molecules are two independent reaction choices; the A homodimer is one + // context choice, so the propensity is 2*k rather than 4*k. + auto model = bng::parser::parseModel(R"( +begin parameters + k 1.0 +end parameters +begin molecule types + A(x) + C(s~0~1) +end molecule types +begin seed species + A(x!1).A(x!1) 1 + C(s~0) 2 +end seed species +begin reaction rules + A(x!1).A(x!1) + C(s~0) -> A(x!1).A(x!1) + C(s~1) k +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, true, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(2.0)); + delete system; +} + +TEST_CASE("NFsim AST adapter counts pure context trimer and scaffold once") { + // Source-derived from akutuva21/nfsim commit 9b2fff8. A single-molecule + // context pattern must collapse all embeddings in one complex, independent + // of pattern or species automorphisms: the trimer distinguishes a factor + // of three, while the scaffold has two distinguishable copies. + auto model = bng::parser::parseModel(R"( +begin parameters + k 1.0 +end parameters +begin molecule types + C(s~0~1) + T(a,b) + Vx(d,t) + Vs(p,q) +end molecule types +begin seed species + C(s~0) 1 + T(a!1,b!3).T(a!2,b!1).T(a!3,b!2) 1 + Vx(d,t!1).Vs(p!1,q!2).Vx(d,t!2) 1 +end seed species +begin reaction rules + C(s~0) + T(a!1,b!3).T(a!2,b!1).T(a!3,b!2) -> C(s~1) + T(a!1,b!3).T(a!2,b!1).T(a!3,b!2) k + C(s~0) + Vx(d) -> C(s~1) + Vx(d) k +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, true, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 2); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(1.0)); + CHECK(system->getReaction(1)->get_a() == Catch::Approx(1.0)); + delete system; +} + +TEST_CASE("NFsim AST adapter preserves transformed homodimer binding multiplicity") { + // Source-derived from akutuva21/nfsim commit 9b2fff8 and its context + // symmetry fixture. The homodimer is a reaction center here: either half + // can bind B, so the propensity remains 2*k. In particular, the EMPTY + // transform on the second binding partner must not make this pure context. + auto model = bng::parser::parseModel(R"( +begin parameters + k 1.0 +end parameters +begin molecule types + B(b) + H(d,c) +end molecule types +begin seed species + B(b) 1 + H(d!1,c).H(d!1,c) 1 +end seed species +begin reaction rules + B(b) + H(d!1,c).H(d!1,c) -> B(b!2).H(d!1,c!2).H(d!1,c) k +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, true, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(2.0)); + delete system; +} + +TEST_CASE("NFsim AST adapter counts pure DOR context once per complex") { + // Source-derived from akutuva21/nfsim commit 9b2fff8 and its + // context_symmetry.bngl R_dor_sym/R_dor_asym pair. The locally scoped + // homodimer is a pure context for the state-changing C molecule, so the + // DOR tree must contribute one matching complex rather than two matching + // embeddings. + auto model = bng::parser::parseModel(R"( +begin parameters + k 1.0 +end parameters +begin molecule types + Src() + C(s~0~1) + W(d) +end molecule types +begin seed species + Src() 1 + C(s~0) 1 + W(d!1).W(d!1) 1 +end seed species +begin observables + Molecules Obs_Src Src() + Molecules Cnt_W W() +end observables +begin functions + locs(x) = k*Obs_Src + 0*Cnt_W(x) +end functions +begin reaction rules + C(s~0) + W(d!1)%x.W(d!1) -> C(s~1) + W(d!1)%x.W(d!1) locs(x) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, true, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + CHECK(system->getReaction(0)->getRxnType() == NFcore::ReactionClass::DOR_RXN); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(1.0)); + delete system; +} + TEST_CASE("NFsim AST adapter maps bare molecule stoichiometric observables") { auto model = bng::parser::parseModel(R"( begin molecule types @@ -1076,6 +1864,11 @@ end reaction rules *model, false, 100, false, suggestedTraversalLimit); REQUIRE(direct != nullptr); REQUIRE(direct->getAllReactions().size() == 2); + auto* initialA = direct->getMoleculeTypeByName("A")->getMolecule(0); + const auto initialB1 = initialA->getMoleculeType()->getCompIndexFromName("b1"); + const auto initialB2 = initialA->getMoleculeType()->getCompIndexFromName("b2"); + CHECK(initialA->isBindingSiteBonded(initialB1) != + initialA->isBindingSiteBonded(initialB2)); CHECK(direct->getAllReactions()[0]->getName() == "R1_sym1"); CHECK(direct->getAllReactions()[1]->getName() == "R1_sym2"); direct->prepareForSimulation(); @@ -1158,6 +1951,9 @@ end reaction rules suggestedTraversalLimit); REQUIRE(system != nullptr); REQUIRE(system->getAllReactions().size() == 1); + auto* initialA = system->getMoleculeTypeByName("A")->getMolecule(0); + const auto initialB = initialA->getMoleculeType()->getCompIndexFromName("b"); + CHECK(initialA->isBindingSiteBonded(initialB)); system->prepareForSimulation(); CHECK(system->getObservableByName("AB")->getCount() == 1); system->seedRNG(3); @@ -1168,6 +1964,126 @@ end reaction rules delete system; } +TEST_CASE("NFsim AST adapter permits multi-bond ring-opening dissociation") { + // Source-derived from NFsim 4b4e514: product molecularity must evaluate + // all bonds deleted by one firing together. Removing either bond alone + // leaves this two-bond homodimer connected, but removing both bonds splits + // it into two products and must be allowed. + auto model = bng::parser::parseModel(R"( +begin parameters + k_reverse 1.0 +end parameters +begin molecule types + M(h,f) +end molecule types +begin seed species + M(h!1,f!2).M(h!2,f!1) 1 +end seed species +begin observables + Species Ring M(h!1,f!2).M(h!2,f!1) + Molecules Monomer M(h,f) +end observables +begin reaction rules + M(h!1,f!2).M(h!2,f!1) -> M(h,f) + M(h,f) k_reverse +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, true, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + system->prepareForSimulation(); + REQUIRE(system->getObservableByName("Ring") != nullptr); + REQUIRE(system->getObservableByName("Monomer") != nullptr); + CHECK(system->getObservableByName("Ring")->getCount() == 1); + CHECK(system->getObservableByName("Monomer")->getCount() == 0); + + NFcore::System::NULL_EVENT_COUNTER = 0; + system->getReaction(0)->fire(0.0); + + CHECK(NFcore::System::NULL_EVENT_COUNTER == 0); + CHECK(system->getObservableByName("Ring")->getCount() == 0); + CHECK(system->getObservableByName("Monomer")->getCount() == 2); + delete system; + + // The XML compatibility path exercises the same embedded NFsim + // TransformationSet implementation and is retained as the migration + // comparator for the direct adapter. + int xmlSuggestedTraversalLimit = 0; + auto* xmlSystem = NFinput::initializeFromModel( + static_cast(model.get()), true, 100, false, + xmlSuggestedTraversalLimit); + REQUIRE(xmlSystem != nullptr); + xmlSystem->prepareForSimulation(); + REQUIRE(xmlSystem->getObservableByName("Ring") != nullptr); + REQUIRE(xmlSystem->getObservableByName("Monomer") != nullptr); + CHECK(xmlSystem->getObservableByName("Ring")->getCount() == 1); + CHECK(xmlSystem->getObservableByName("Monomer")->getCount() == 0); + NFcore::System::NULL_EVENT_COUNTER = 0; + xmlSystem->getReaction(0)->fire(0.0); + CHECK(NFcore::System::NULL_EVENT_COUNTER == 0); + CHECK(xmlSystem->getObservableByName("Ring")->getCount() == 0); + CHECK(xmlSystem->getObservableByName("Monomer")->getCount() == 2); + delete xmlSystem; +} + +TEST_CASE("NFsim AST adapter blocks single-bond ring dissociation") { + // The negative control from the same NFsim source fix: a single deleted + // bond must remain blocked when an alternate path keeps the two products + // in one connected complex. + auto model = bng::parser::parseModel(R"( +begin parameters + k_reverse 1.0 +end parameters +begin molecule types + L(a,b) + R(c,d) +end molecule types +begin seed species + L(a!1,b!2).L(a!3,b!4).R(c!2,d!3).R(c!4,d!1) 1 +end seed species +begin observables + Species Ring L(a!1,b!2).L(a!3,b!4).R(c!2,d!3).R(c!4,d!1) +end observables +begin reaction rules + L(a!1).R(c!1) -> L(a) + R(c) k_reverse +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, true, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + system->prepareForSimulation(); + REQUIRE(system->getObservableByName("Ring") != nullptr); + CHECK(system->getObservableByName("Ring")->getCount() == 1); + + NFcore::System::NULL_EVENT_COUNTER = 0; + system->getReaction(0)->fire(0.0); + + CHECK(NFcore::System::NULL_EVENT_COUNTER == 1); + CHECK(system->getObservableByName("Ring")->getCount() == 1); + delete system; + + int xmlSuggestedTraversalLimit = 0; + auto* xmlSystem = NFinput::initializeFromModel( + static_cast(model.get()), true, 100, false, + xmlSuggestedTraversalLimit); + REQUIRE(xmlSystem != nullptr); + xmlSystem->prepareForSimulation(); + REQUIRE(xmlSystem->getObservableByName("Ring") != nullptr); + CHECK(xmlSystem->getObservableByName("Ring")->getCount() == 1); + NFcore::System::NULL_EVENT_COUNTER = 0; + xmlSystem->getReaction(0)->fire(0.0); + CHECK(NFcore::System::NULL_EVENT_COUNTER == 1); + CHECK(xmlSystem->getObservableByName("Ring")->getCount() == 1); + delete xmlSystem; +} + TEST_CASE("NFsim AST adapter creates direct standalone product molecules") { auto model = bng::parser::parseModel(R"( begin parameters @@ -1241,6 +2157,46 @@ end reaction rules delete system; } +TEST_CASE("NFsim AST adapter maps intramolecular product bonds") { + auto model = bng::parser::parseModel(R"( +begin parameters + k 1.0 +end parameters +begin molecule types + Source() + A(x,x) +end molecule types +begin seed species + Source() 1 +end seed species +begin observables + Species A_ring A(x!1,x!1) +end observables +begin reaction rules + Source() -> Source() + A(x!1,x!1) k +end reaction rules +)"); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + system->prepareForSimulation(); + CHECK(system->getObservableByName("A_ring")->getCount() == 0); + system->seedRNG(8); + system->stepTo(100.0); + CHECK(system->getObservableByName("A_ring")->getCount() > 0); + auto* moleculeType = system->getMoleculeTypeByName("A"); + REQUIRE(moleculeType != nullptr); + REQUIRE(moleculeType->getMolecule(0) != nullptr); + const auto x1 = moleculeType->getCompIndexFromName("x1"); + const auto x2 = moleculeType->getCompIndexFromName("x2"); + CHECK(moleculeType->getMolecule(0)->isBindingSiteBonded(x1)); + CHECK(moleculeType->getMolecule(0)->isBindingSiteBonded(x2)); + delete system; +} + TEST_CASE("NFsim AST adapter expands direct reversible reaction rules") { auto model = bng::parser::parseModel(R"( begin parameters @@ -1317,11 +2273,14 @@ end reaction rules delete system; } -TEST_CASE("NFsim AST adapter maps Sat rates directly and through XML") { +TEST_CASE("NFsim Michaelis-Menten propensity remains stable for tiny Km") { + // Regression distilled from NFsim/test/MM/mm_small_km.bngl. With enzyme + // in excess and Km far below one, the naive quadratic-root expression + // loses the small positive substrate root to floating-point cancellation. auto model = bng::parser::parseModel(R"( begin parameters kcat 1.0 - Km 10.0 + Km 1e-15 end parameters begin molecule types S() @@ -1330,24 +2289,62 @@ begin molecule types end molecule types begin seed species S() 100 - E() 100 + E() 200 + P() 0 end seed species begin observables Molecules S_total S() Molecules P_total P() end observables begin reaction rules - S() + E() -> P() + E() Sat(kcat,Km) + S() + E() -> P() + E() MM(kcat,Km) end reaction rules )"); - REQUIRE(model != nullptr); - const auto xml = bng::io::XmlWriter::write(*model); - CHECK(xml.find("type=\"Sat\"") != std::string::npos); - int suggestedTraversalLimit = 0; - auto* direct = NFinput::buildSystemFromAst( - *model, false, 100, false, suggestedTraversalLimit); + auto* system = NFinput::buildSystemFromAst( + *model, false, 1000, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(100.0)); + system->singleStep(); + CHECK(system->getObservableByName("S_total")->getCount() == 99); + CHECK(system->getObservableByName("P_total")->getCount() == 1); + delete system; +} + +TEST_CASE("NFsim AST adapter maps Sat rates directly and through XML") { + auto model = bng::parser::parseModel(R"( +begin parameters + kcat 1.0 + Km 10.0 +end parameters +begin molecule types + S() + E() + P() +end molecule types +begin seed species + S() 100 + E() 100 +end seed species +begin observables + Molecules S_total S() + Molecules P_total P() +end observables +begin reaction rules + S() + E() -> P() + E() Sat(kcat,Km) +end reaction rules +)"); + + REQUIRE(model != nullptr); + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("type=\"Sat\"") != std::string::npos); + + int suggestedTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); REQUIRE(direct != nullptr); REQUIRE(direct->getAllReactions().size() == 1); direct->prepareForSimulation(); @@ -1446,6 +2443,46 @@ end reaction rules delete system; } +TEST_CASE("NFsim AST adapter applies zero-order compartment volume conversion") { + auto model = bng::parser::parseModel(R"( +begin parameters + k 2.0 +end parameters +begin compartments + cell 3 4 +end compartments +begin molecule types + Product(site~U~P) +end molecule types +begin observables + Molecules product_P Product@cell(site~P) +end observables +begin reaction rules + 0 -> Product@cell(site~P) k +end reaction rules +)"); + REQUIRE(model != nullptr); + model->setOption("NumberPerQuantityUnit", "10"); + + int directTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, directTraversalLimit); + REQUIRE(direct != nullptr); + REQUIRE(direct->getAllReactions().size() == 1); + direct->prepareForSimulation(); + CHECK(direct->getReaction(0)->get_a() == Catch::Approx(80.0)); + delete direct; + + int xmlTraversalLimit = 0; + auto* xml = NFinput::initializeFromModel( + static_cast(model.get()), false, 100, false, xmlTraversalLimit); + REQUIRE(xml != nullptr); + REQUIRE(xml->getAllReactions().size() == 1); + xml->prepareForSimulation(); + CHECK(xml->getReaction(0)->get_a() == Catch::Approx(80.0)); + delete xml; +} + TEST_CASE("NFsim AST adapter maps zero-argument functional reaction rates") { auto model = bng::parser::parseModel(R"( begin parameters @@ -1484,6 +2521,199 @@ end reaction rules delete system; } +TEST_CASE("NFsim AST adapter preserves source reactant-count rate references") { + // Source-derived from NFsim's IfTest fixture: reactant_N() is a + // per-reaction mapping-count value supplied by CompositeFunction, not a + // model observable or a user-declared global function. + auto model = bng::parser::parseModel(R"( +begin parameters + k 2.0 +end parameters +begin molecule types + A(x) + B(x) +end molecule types +begin seed species + A(x) 3 +end seed species +begin functions + reactant_1() + rateLaw k*reactant_1() +end functions +begin reaction rules + A(x) -> B(x) rateLaw() +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(direct != nullptr); + REQUIRE(direct->getAllReactions().size() == 1); + REQUIRE(direct->getCompositeFunctionByName("rateLaw") != nullptr); + CHECK(direct->getReaction(0)->getRxnType() == + NFcore::ReactionClass::OBS_DEPENDENT_RXN); + direct->prepareForSimulation(); + CHECK(direct->getReaction(0)->get_a() == Catch::Approx(18.0)); + delete direct; + + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("") != + std::string::npos); + suggestedTraversalLimit = 0; + auto* xmlSystem = NFinput::initializeFromModel( + static_cast(model.get()), false, 100, false, suggestedTraversalLimit); + REQUIRE(xmlSystem != nullptr); + REQUIRE(xmlSystem->getAllReactions().size() == 1); + REQUIRE(xmlSystem->getCompositeFunctionByName("rateLaw") != nullptr); + xmlSystem->prepareForSimulation(); + CHECK(xmlSystem->getReaction(0)->get_a() == Catch::Approx(18.0)); + delete xmlSystem; +} + +TEST_CASE("NFsim AST adapter maps the IfTest conditional function rates") { + // Source-derived from nfsim/test/IfTest/ifTest.bngl and its generated XML: + // conditional global functions reference a live molecule observable, while + // the reaction laws multiply the per-reaction reactant-count placeholder. + auto model = bng::parser::parseModel(R"BNG( +begin parameters +end parameters +begin molecule types + Timer(t~OFF~ON) + DelayedStartMolecule(p~U~P) +end molecule types +begin seed species + Timer(t~OFF) 10000 + DelayedStartMolecule(p~U) 10000 +end seed species +begin observables + Molecules Toff Timer(t~OFF) + Molecules Ton Timer(t~ON) + Molecules Du DelayedStartMolecule(p~U) + Molecules Dp DelayedStartMolecule(p~P) +end observables +begin functions + kDelay() = if(Ton>5000&&Ton<7000,2,0) + kDeplete() = if(Ton>7000,2,0) + reactant_1() +end functions +begin reaction rules + Timer(t~OFF) -> Timer(t~ON) 0.5 + DelayedStartMolecule(p~U) -> DelayedStartMolecule(p~P) reactant_1()*kDelay() + DelayedStartMolecule(p~P) -> DelayedStartMolecule(p~U) reactant_1()*kDeplete() +end reaction rules +)BNG"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 10000, false, suggestedTraversalLimit); + REQUIRE(direct != nullptr); + REQUIRE(direct->getAllReactions().size() == 3); + REQUIRE(direct->getGlobalFunctionByName("kDelay") != nullptr); + REQUIRE(direct->getGlobalFunctionByName("kDeplete") != nullptr); + CHECK(direct->getGlobalFunctionByName("kDelay")->getNumOfVarRefs() == 1); + CHECK(direct->getGlobalFunctionByName("kDelay")->getVarRefName(0) == "Ton"); + CHECK(direct->getGlobalFunctionByName("kDeplete")->getNumOfVarRefs() == 1); + CHECK(direct->getGlobalFunctionByName("kDeplete")->getVarRefName(0) == "Ton"); + direct->prepareForSimulation(); + CHECK(direct->getReaction(0)->get_a() == Catch::Approx(5000.0)); + CHECK(direct->getReaction(1)->get_a() == Catch::Approx(0.0)); + CHECK(direct->getReaction(2)->get_a() == Catch::Approx(0.0)); + + // Exercise both sides of the live Ton thresholds, rather than only the + // initial false branch. Exact seeded molecule counts remain a separate + // parity gate: native NFsim uses its legacy global mapping RNG, while the + // BNG3 adapter deliberately uses a per-System RNG stream. + const auto checkIfTestTrajectory = [](NFcore::System* system) { + // Independent native NFsim a6f9fa9 oracle, using + // nfsim/test/IfTest/ifTest.xml, seed 1, and output times + // 0.5, 1, 2, 3, 5. These exact values are a parity contract, not a + // tolerance or a replacement for the branch-semantic checks below. + const std::vector nativeTon{2236, 3944, 6306, 7787, 9176}; + std::size_t checkpoint = 0; + const auto count = [system](const char* name) { + return system->getObservableByName(name)->getCount(); + }; + const auto checkConservation = [&count]() { + CHECK(count("Toff") + count("Ton") == 10000); + CHECK(count("Du") + count("Dp") == 10000); + }; + + system->seedRNG(1); + system->stepTo(0.5); + checkConservation(); + CHECK(count("Ton") == nativeTon[checkpoint++]); + CHECK(count("Ton") > 0); + CHECK(count("Ton") < 5000); + CHECK(count("Du") == 10000); + CHECK(count("Dp") == 0); + CHECK(NFcore::FuncFactory::Eval( + system->getGlobalFunctionByName("kDelay")->p) == + Catch::Approx(0.0)); + + system->stepTo(1.0); + checkConservation(); + CHECK(count("Ton") == nativeTon[checkpoint++]); + CHECK(count("Ton") > 0); + CHECK(count("Ton") < 5000); + + system->stepTo(2.0); + checkConservation(); + CHECK(count("Ton") == nativeTon[checkpoint++]); + CHECK(count("Ton") > 5000); + CHECK(count("Ton") < 7000); + CHECK(NFcore::FuncFactory::Eval( + system->getGlobalFunctionByName("kDelay")->p) == + Catch::Approx(2.0)); + CHECK(NFcore::FuncFactory::Eval( + system->getGlobalFunctionByName("kDeplete")->p) == + Catch::Approx(0.0)); + + system->stepTo(3.0); + checkConservation(); + CHECK(count("Ton") == nativeTon[checkpoint++]); + CHECK(count("Ton") > 7000); + CHECK(NFcore::FuncFactory::Eval( + system->getGlobalFunctionByName("kDelay")->p) == + Catch::Approx(0.0)); + CHECK(NFcore::FuncFactory::Eval( + system->getGlobalFunctionByName("kDeplete")->p) == + Catch::Approx(2.0)); + + system->stepTo(5.0); + checkConservation(); + CHECK(count("Ton") == nativeTon[checkpoint++]); + CHECK(count("Ton") > 7000); + }; + checkIfTestTrajectory(direct); + delete direct; + + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("") != std::string::npos); + CHECK(xml.find("if(") != std::string::npos); + CHECK(xml.find("Ton") != std::string::npos); + CHECK(xml.find("5000") != std::string::npos); + CHECK(xml.find("&&") != std::string::npos); + CHECK(xml.find("&&") == std::string::npos); + CHECK(xml.find("") != + std::string::npos); + + suggestedTraversalLimit = 0; + auto* xmlSystem = NFinput::initializeFromModel( + static_cast(model.get()), false, 10000, false, suggestedTraversalLimit); + REQUIRE(xmlSystem != nullptr); + REQUIRE(xmlSystem->getAllReactions().size() == 3); + xmlSystem->prepareForSimulation(); + CHECK(xmlSystem->getReaction(0)->get_a() == Catch::Approx(5000.0)); + CHECK(xmlSystem->getReaction(1)->get_a() == Catch::Approx(0.0)); + CHECK(xmlSystem->getReaction(2)->get_a() == Catch::Approx(0.0)); + + checkIfTestTrajectory(xmlSystem); + delete xmlSystem; +} + TEST_CASE("NFsim AST adapter maps dynamic observable reaction rates directly and through XML") { auto model = bng::parser::parseModel(R"( begin parameters @@ -1843,6 +3073,127 @@ end reaction rules delete system; } +TEST_CASE("NFsim XML writer wraps a scoped local function rate") { + // Source-derived from nfsim/test/testSuite/t3.xml: legacy NFsim expects + // a scoped local function to be called through a composite wrapper + // (pOn -> _rateLaw1), rather than using the LocalFunction directly as the + // reaction RateLaw name. The wrapper keeps the XML interchange path + // consumable by an independently built native NFsim binary. + auto model = bng::parser::parseModel(R"( +begin parameters + k 2.0 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() 1 +end seed species +begin observables + Molecules atotal A() +end observables +begin functions + f(x) = k*atotal(x) +end functions +begin reaction rules + %x::A() -> %x::A() + B() f(x) +end reaction rules +)"); + + REQUIRE(model != nullptr); + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find( + "") != + std::string::npos); + CHECK(xml.find( + "") != + std::string::npos); + CHECK(xml.find("f(x)") != std::string::npos); +} + +TEST_CASE("NFsim AST adapter maps arithmetic around a scoped local function rate") { + auto model = bng::parser::parseModel(R"( +begin parameters + k 2.0 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() 1 +end seed species +begin observables + Molecules atotal A() +end observables +begin functions + f(x) = atotal(x) +end functions +begin reaction rules + %x::A() -> %x::A() + B() k + f(x) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(direct != nullptr); + REQUIRE(direct->getCompositeFunctionByName("__bng3_reaction_rate_1") != nullptr); + REQUIRE(direct->getAllReactions().size() == 1); + CHECK(direct->getReaction(0)->getRxnType() == NFcore::ReactionClass::DOR_RXN); + direct->prepareForSimulation(); + CHECK(direct->getReaction(0)->get_a() == Catch::Approx(3.0)); + delete direct; +} + +TEST_CASE("NFsim XML bridge maps arithmetic around a scoped local function rate") { + auto model = bng::parser::parseModel(R"( +begin parameters + k 2.0 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() 1 +end seed species +begin observables + Molecules atotal A() +end observables +begin functions + f(x) = atotal(x) +end functions +begin reaction rules + %x::A() -> %x::A() + B() k + f(x) +end reaction rules +)"); + + REQUIRE(model != nullptr); + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("") != std::string::npos); + CHECK(xml.find("(k + f(x))") != + std::string::npos); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::initializeFromModel( + static_cast(model.get()), false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getCompositeFunctionByName("__bng3_reaction_rate_RR1") != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(3.0)); + delete system; +} + TEST_CASE("NFsim AST adapter maps a bounded nested local function rate") { auto model = bng::parser::parseModel(R"( begin parameters @@ -1865,12 +3216,16 @@ end functions begin reaction rules %x::A() -> %x::A() + B() f(x) end reaction rules -)"); + )"); REQUIRE(model != nullptr); const auto xml = bng::io::XmlWriter::write(*model); CHECK(xml.find("") != std::string::npos); + CHECK(xml.find( + " %x::A() + B() f(x) + Rdeg: A() -> 0 k_deg + Rps: 0 -> Ps() fs() + Rpm: 0 -> Pm() fm() end reaction rules )"); REQUIRE(model != nullptr); - const auto xml = bng::io::XmlWriter::write(*model); - CHECK(xml.find("type=\"TFUN\"") != std::string::npos); - CHECK(xml.find("__TFUN_VAL__") != std::string::npos); - int suggestedTraversalLimit = 0; - auto* direct = NFinput::buildSystemFromAst( + auto* system = NFinput::buildSystemFromAst( *model, false, 100, false, suggestedTraversalLimit); - REQUIRE(direct != nullptr); - REQUIRE(direct->getLocalFunctionByName("f") != nullptr); - REQUIRE(direct->getAllReactions().size() == 1); - REQUIRE(direct->getHasTimeDependentFunctions()); - direct->prepareForSimulation(); - CHECK(direct->getReaction(0)->get_a() == Catch::Approx(3.0)); - *direct->getCurrentTimePtr() = 0.5; + REQUIRE(system != nullptr); + system->prepareForSimulation(); + + auto* degradation = system->getReaction(0); + auto* productionPs = system->getReaction(1); + auto* productionPm = system->getReaction(2); + REQUIRE(degradation != nullptr); + REQUIRE(productionPs != nullptr); + REQUIRE(productionPm != nullptr); + CHECK(system->getObservableByName("Sobs")->getCount() == 100); + CHECK(system->getObservableByName("Mobs")->getCount() == 100); + CHECK(productionPs->get_a() == Catch::Approx(100.0)); + CHECK(productionPm->get_a() == Catch::Approx(100.0)); + + degradation->fire(0.0); + + CHECK(system->getObservableByName("Sobs")->getCount() == 99); + CHECK(system->getObservableByName("Mobs")->getCount() == 99); + CHECK(productionPs->get_a() == Catch::Approx(99.0)); + CHECK(productionPm->get_a() == Catch::Approx(99.0)); + delete system; +} + +TEST_CASE("NFsim equilibrate uses the absolute current time") { + // Source-derived from NFsim afad408606ee3e8f579ff50269d266bc66dae0b2: + // equilibrate receives a duration but must advance from the system's + // current absolute clock before restoring that clock. + auto model = bng::parser::parseModel(R"( +begin parameters + k 100.0 +end parameters +begin molecule types + B() +end molecule types +begin seed species + B() 0 +end seed species +begin observables + Molecules B_total B() +end observables +begin reaction rules + R: 0 -> B() k +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, false, 1000, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + system->seedRNG(1); + system->prepareForSimulation(); + + system->stepTo(1.0); + const auto before = system->getObservableByName("B_total")->getCount(); + system->equilibrate(0.25); + + CHECK(system->getCurrentTime() == Catch::Approx(1.0)); + CHECK(system->getObservableByName("B_total")->getCount() > before); + delete system; +} + +TEST_CASE("NFsim AST adapter maps time-backed local TFUN rates") { + auto model = bng::parser::parseModel(R"( +begin parameters + k 1.0 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() 1 +end seed species +begin observables + Molecules atotal A() +end observables +begin functions + f(x) = TFUN([0, 1], [2, 4], time) + atotal(x) +end functions +begin reaction rules + %x::A() -> %x::A() + B() f(x) +end reaction rules +)"); + + REQUIRE(model != nullptr); + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("type=\"TFUN\"") != std::string::npos); + CHECK(xml.find("__TFUN_VAL__") != std::string::npos); + + int suggestedTraversalLimit = 0; + auto* direct = NFinput::buildSystemFromAst( + *model, false, 100, false, suggestedTraversalLimit); + REQUIRE(direct != nullptr); + REQUIRE(direct->getLocalFunctionByName("f") != nullptr); + REQUIRE(direct->getAllReactions().size() == 1); + REQUIRE(direct->getHasTimeDependentFunctions()); + direct->prepareForSimulation(); + CHECK(direct->getReaction(0)->get_a() == Catch::Approx(3.0)); + *direct->getCurrentTimePtr() = 0.5; CHECK(direct->getReaction(0)->update_a() == Catch::Approx(4.0)); delete direct; @@ -2444,6 +3901,209 @@ end reaction rules delete system; } +TEST_CASE("NFsim AST adapter maps a raw local-function product rate") { + auto model = bng::parser::parseModel(R"BNG( +begin molecule types + A() + B() +end molecule types +begin seed species + A() 1 + B() 1 +end seed species +begin observables + Molecules atotal A() + Molecules btotal B() +end observables +begin functions + fA(x) = atotal(x) + fB(y) = btotal(y) +end functions +begin reaction rules + %x::A() + %y::B() -> %x::A() + %y::B() fA(x)*fB(y) +end reaction rules +)BNG"); + + REQUIRE(model != nullptr); + REQUIRE(model->getReactionRules().size() == 1); + const auto& rate = model->getReactionRules().front().getRates().front(); + REQUIRE(rate.kind() == bng::ast::ExpressionKind::Binary); + CHECK(rate.name() == "*"); + REQUIRE(rate.args().size() == 2); + CHECK(rate.args()[0].name() == "fA"); + CHECK(rate.args()[1].name() == "fB"); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + CHECK(system->getReaction(0)->getRxnType() == NFcore::ReactionClass::DOR2_RXN); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(1.0)); + delete system; +} + +TEST_CASE("NFsim AST adapter maps legacy-tagged raw local-function products") { + auto model = bng::parser::parseModel(R"BNG( +begin molecule types + A() + B() +end molecule types +begin seed species + A() 1 + B() 1 +end seed species +begin observables + Molecules atotal A() + Molecules btotal B() +end observables +begin functions + fA(x) = atotal(x) + fB(y) = btotal(y) +end functions +begin reaction rules + A%x() + B%y() -> A%x() + B%y() fA(x)*fB(y) +end reaction rules +)BNG"); + + REQUIRE(model != nullptr); + REQUIRE(model->getReactionRules().size() == 1); + const auto& rule = model->getReactionRules().front(); + REQUIRE(rule.getReactants().size() == 2); + CHECK(rule.getReactants()[0] == "A%x()"); + CHECK(rule.getReactants()[1] == "B%y()"); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + CHECK(system->getReaction(0)->getRxnType() == NFcore::ReactionClass::DOR2_RXN); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(1.0)); + delete system; +} + +TEST_CASE("NFsim XML bridge preserves raw local-function products") { + auto model = bng::parser::parseModel(R"BNG( +begin molecule types + A() + B() +end molecule types +begin seed species + A() 1 + B() 1 +end seed species +begin observables + Molecules atotal A() + Molecules btotal B() +end observables +begin functions + fA(x) = atotal(x) + fB(y) = btotal(y) +end functions +begin reaction rules + A%x() + B%y() -> A%x() + B%y() fA(x)*fB(y) +end reaction rules +)BNG"); + + REQUIRE(model != nullptr); + const auto xml = bng::io::XmlWriter::write(*model); + CHECK(xml.find("type=\"FunctionProduct\"") != std::string::npos); + CHECK(xml.find("name1=\"fA\"") != std::string::npos); + CHECK(xml.find("name2=\"fB\"") != std::string::npos); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::initializeFromModel( + static_cast(model.get()), false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + CHECK(system->getReaction(0)->getRxnType() == NFcore::ReactionClass::DOR2_RXN); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(1.0)); + delete system; +} + +TEST_CASE("NFsim XML bridge reads legacy composite DOR2 rate laws") { + auto model = bng::parser::parseModel(R"BNG( +begin molecule types + A() + B() +end molecule types +begin seed species + A() 1 + B() 1 +end seed species +begin observables + Molecules atotal A() + Molecules btotal B() +end observables +begin functions + fA(x) = atotal(x) + fB(y) = btotal(y) +end functions +begin reaction rules + A%x() + B%y() -> A%x() + B%y() fA(x)*fB(y) +end reaction rules +)BNG"); + + REQUIRE(model != nullptr); + auto xml = bng::io::XmlWriter::write(*model); + const auto rateStart = xml.find(" + + + + + +)XML"; + xml.replace(rateStart, rateEnd + rateEndMarker.size() - rateStart, legacyRate); + + const auto functionsEnd = xml.find(" "); + REQUIRE(functionsEnd != std::string::npos); + const std::string legacyComposite = R"XML( + + + + + + + + + + + fA(x)*fB(y) + +)XML"; + xml.insert(functionsEnd, legacyComposite); + + const auto token = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto xmlPath = std::filesystem::temp_directory_path() / + ("bng3-legacy-dor2-" + std::to_string(token) + ".xml"); + std::ofstream xmlFile(xmlPath); + REQUIRE(xmlFile.good()); + xmlFile << xml; + xmlFile.close(); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::initializeFromXML( + xmlPath.string(), false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + CHECK(system->getReaction(0)->getRxnType() == NFcore::ReactionClass::DOR2_RXN); + system->prepareForSimulation(); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(1.0)); + delete system; + + std::error_code error; + std::filesystem::remove(xmlPath, error); +} + TEST_CASE("NFsim XML bridge preserves bounded FunctionProduct rates") { auto model = bng::parser::parseModel(R"BNG( begin molecule types @@ -2562,28 +4222,30 @@ end reaction rules delete system; } -TEST_CASE("NFsim AST adapter expands Arrhenius binding context variants") { +TEST_CASE("NFsim AST adapter expands one-way direct Arrhenius binding") { + // BNG2 2.9.3 expands this unidirectional rule to one forward network + // reaction with exp(-(Ea + phi*Gbind)); it does not synthesize a reverse + // reaction. Keep that source-derived directionality contract explicit. auto model = bng::parser::parseModel(R"( begin parameters - phi 0.5 + phi 0.25 Ea 0.0 - Gcontext 1.0 + Gbind 1.0 RT 1.0 end parameters begin molecule types - A(b,c) + A(b) B(a) - C(x) end molecule types begin seed species - A(b,c!1).C(x!1) 1 + A(b) 1 B(a) 1 end seed species begin energy patterns - A(b!1,c!2).B(a!1).C(x!2) Gcontext + A(b!1).B(a!1) Gbind end energy patterns begin reaction rules - A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) -> A(b!1).B(a!1) Arrhenius(phi,Ea) end reaction rules )"); @@ -2592,10 +4254,1611 @@ end reaction rules auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, suggestedTraversalLimit); REQUIRE(system != nullptr); - REQUIRE(system->getAllReactions().size() == 4); - CHECK(system->getReaction(0)->getBaseRate() == Catch::Approx(1.0)); - CHECK(system->getReaction(1)->getBaseRate() == Catch::Approx(1.0)); - CHECK(system->getReaction(2)->getBaseRate() == Catch::Approx(std::exp(-0.5))); - CHECK(system->getReaction(3)->getBaseRate() == Catch::Approx(std::exp(0.5))); + REQUIRE(system->getEnergyFunction() != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + CHECK(system->getReaction(0)->getBaseRate() == Catch::Approx(std::exp(-0.25))); + CHECK(suggestedTraversalLimit >= 2); delete system; } + +TEST_CASE("NFsim AST adapter expands one-way direct Arrhenius state changes") { + // BNG2 2.9.3 emits one forward reaction for this rule with + // exp(-(Ea + phi*(Gup-Gdn))); no reverse direction is implicit. + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.25 + Ea 0.0 + Gup 1.0 + Gdn 0.0 + RT 1.0 +end parameters +begin molecule types + A(conf~dn~up) +end molecule types +begin seed species + A(conf~dn) 1 +end seed species +begin energy patterns + A(conf~up) Gup + A(conf~dn) Gdn +end energy patterns +begin reaction rules + A(conf~dn) -> A(conf~up) Arrhenius(phi,Ea) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getEnergyFunction() != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + CHECK(system->getReaction(0)->getBaseRate() == Catch::Approx(std::exp(-0.25))); + CHECK(suggestedTraversalLimit >= 1); + delete system; +} + +TEST_CASE("NFsim AST adapter retains materialized expansion for non-factorized contexts") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Ea 0.0 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a) + C(x) +end molecule types +begin seed species + A(b,c!1).C(x!1) 1 + B(a) 1 +end seed species +begin energy patterns + A(b!1).A(c!2).B(a!1).C(x!2) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 4); + CHECK(system->getReaction(0)->getBaseRate() == Catch::Approx(1.0)); + CHECK(system->getReaction(1)->getBaseRate() == Catch::Approx(1.0)); + CHECK(system->getReaction(2)->getBaseRate() == Catch::Approx(std::exp(-0.5))); + CHECK(system->getReaction(3)->getBaseRate() == Catch::Approx(std::exp(0.5))); + delete system; +} + +TEST_CASE("NFsim energy function exposes compact binding context") { + NFcore::EnergyFunction energy(0.5, 1.0); + + NFcore::EnergyPatternInfo pattern; + pattern.id = "A_context_B"; + pattern.energyValue = 2.0; + + NFcore::EpMolecule moleculeA; + moleculeA.typeName = "A"; + moleculeA.xmlId = "a"; + moleculeA.components.push_back({"b", "b", true, ""}); + moleculeA.components.push_back({"c", "c", true, ""}); + + NFcore::EpMolecule moleculeB; + moleculeB.typeName = "B"; + moleculeB.xmlId = "b"; + moleculeB.components.push_back({"a", "a", true, ""}); + + NFcore::EpMolecule moleculeC; + moleculeC.typeName = "C"; + moleculeC.xmlId = "c"; + moleculeC.components.push_back({"x", "x", true, ""}); + + pattern.molecules = {moleculeA, moleculeB, moleculeC}; + pattern.bonds.push_back({0, 0, 1, 0}); + pattern.bonds.push_back({0, 1, 2, 0}); + energy.addEnergyPattern(pattern); + + NFcore::EnergyBindingContext context; + REQUIRE(energy.getBindingContext("A", "b", "B", "a", context)); + CHECK(context.baseEnergy == Catch::Approx(0.0)); + REQUIRE(context.conditions.size() == 1); + CHECK(context.conditions.front().reactantIdx == 0); + CHECK(context.conditions.front().molType == "A"); + CHECK(context.conditions.front().compName == "c"); + CHECK(context.conditions.front().partnerType == "C"); + CHECK(context.conditions.front().partnerComp == "x"); + REQUIRE(context.conditionalTerms.size() == 1); + CHECK(context.conditionalTerms.front().energyValue == Catch::Approx(2.0)); + CHECK(context.conditionalTerms.front().conditionMask == 1); +} + +TEST_CASE("NFsim energy function rejects non-factorized compact contexts") { + NFcore::EnergyFunction energy(0.5, 1.0); + + NFcore::EnergyPatternInfo pattern; + pattern.id = "A_duplicate_context_B"; + pattern.energyValue = 1.0; + + NFcore::EpMolecule firstA; + firstA.typeName = "A"; + firstA.xmlId = "a1"; + firstA.components.push_back({"b", "b", true, ""}); + + NFcore::EpMolecule secondA; + secondA.typeName = "A"; + secondA.xmlId = "a2"; + secondA.components.push_back({"c", "c", true, ""}); + + NFcore::EpMolecule moleculeB; + moleculeB.typeName = "B"; + moleculeB.xmlId = "b"; + moleculeB.components.push_back({"a", "a1", true, ""}); + + NFcore::EpMolecule moleculeC; + moleculeC.typeName = "C"; + moleculeC.xmlId = "c"; + moleculeC.components.push_back({"x", "x", true, ""}); + + pattern.molecules = {firstA, secondA, moleculeB, moleculeC}; + pattern.bonds.push_back({0, 0, 2, 0}); + pattern.bonds.push_back({1, 0, 3, 0}); + energy.addEnergyPattern(pattern); + + NFcore::EnergyBindingContext context; + CHECK_FALSE(energy.getBindingContext("A", "b", "B", "a", context)); + CHECK(context.conditionalTerms.empty()); +} + +TEST_CASE("NFsim EnergyFunction expands source-derived Arrhenius rules") { + // Source-derived from NFsim f63d676 (test_energyPattern): the public + // EnergyFunction contract must retain pattern storage and calculate the + // forward/reverse Arrhenius rates for binding and state-change rules. + NFcore::EnergyFunction energy(0.5, 1.0); + CHECK(energy.getPhi() == Catch::Approx(0.5)); + CHECK(energy.getRT() == Catch::Approx(1.0)); + CHECK(energy.getNumPatterns() == 0); + + NFcore::EnergyPatternInfo bindingPattern; + bindingPattern.id = "EP1"; + bindingPattern.energyValue = -5.0; + + NFcore::EpMolecule moleculeA; + moleculeA.typeName = "A"; + moleculeA.xmlId = "m1"; + moleculeA.components.push_back({"b", "m2", true, ""}); + + NFcore::EpMolecule moleculeB; + moleculeB.typeName = "B"; + moleculeB.xmlId = "m2"; + moleculeB.components.push_back({"a", "m1", true, ""}); + + bindingPattern.molecules = {moleculeA, moleculeB}; + bindingPattern.bonds.push_back({0, 0, 1, 0}); + energy.addEnergyPattern(bindingPattern); + + CHECK(energy.getNumPatterns() == 1); + CHECK(energy.getPattern(0).id == "EP1"); + + const auto bindingRules = energy.expandBindingRule( + "Rxn1", 10.0, 0.5, "A", "b", "B", "a"); + REQUIRE(bindingRules.size() == 2); + CHECK(bindingRules[0].name == "Rxn1_fwd"); + CHECK(bindingRules[0].isForward); + CHECK(bindingRules[0].deltaG == Catch::Approx(-5.0)); + CHECK(bindingRules[0].rate == Catch::Approx(std::exp(-7.5))); + CHECK(bindingRules[1].name == "Rxn1_rev"); + CHECK_FALSE(bindingRules[1].isForward); + CHECK(bindingRules[1].deltaG == Catch::Approx(-5.0)); + CHECK(bindingRules[1].rate == Catch::Approx(std::exp(-12.5))); + + NFcore::EnergyPatternInfo statePattern; + statePattern.id = "EP2"; + statePattern.energyValue = -3.0; + NFcore::EpMolecule moleculeC; + moleculeC.typeName = "C"; + moleculeC.xmlId = "m3"; + moleculeC.components.push_back({"p", "", false, "phos"}); + statePattern.molecules.push_back(moleculeC); + energy.addEnergyPattern(statePattern); + + const auto stateRules = energy.expandStateChangeRule( + "RxnState", 10.0, 0.5, "C", "p", "unphos", "phos"); + REQUIRE(stateRules.size() == 2); + CHECK(stateRules[0].name == "RxnState_fwd"); + CHECK(stateRules[0].deltaG == Catch::Approx(-3.0)); + CHECK(stateRules[0].rate == Catch::Approx(std::exp(-8.5))); + CHECK(stateRules[1].name == "RxnState_rev"); + CHECK_FALSE(stateRules[1].isForward); + CHECK(stateRules[1].deltaG == Catch::Approx(-3.0)); + CHECK(stateRules[1].rate == Catch::Approx(std::exp(-11.5))); +} + +TEST_CASE("NFsim AST adapter uses compact factorized energy evaluation") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c,d) + B(a) + C(x) + D(x) +end molecule types +begin seed species + A(b,c!1,d!2).C(x!1).D(x!2) 1 + A(b,c!3).C(x!3) 1 + A(b) 1 + B(a) 1 +end seed species +begin energy patterns + A(b!1,c!2,d!3).B(a!1).C(x!2).D(x!3) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 2); + CHECK(system->getReaction(0)->getRxnType() == NFcore::ReactionClass::DOR_RXN); + auto* compactReaction = + dynamic_cast(system->getReaction(0)); + REQUIRE(compactReaction != nullptr); + CHECK(compactReaction->usesIncrementalMembership()); + CHECK(compactReaction->supportsSparseSelection()); + CHECK(compactReaction->membershipDecisionIsTypeInvariant()); + REQUIRE(compactReaction->getCompactPartnerPool() != nullptr); + system->turnOff_OnTheFlyObs(); + CHECK(compactReaction->canUseDirectProductList()); + NFcore::IncrementalMembershipChange membershipChange; + REQUIRE(compactReaction->getIncrementalMembershipChange(membershipChange)); + CHECK(membershipChange.moleculeType1 == system->getMoleculeTypeByName("A")); + CHECK(membershipChange.componentIndex1 == + system->getMoleculeTypeByName("A")->getCompIndexFromName("b")); + CHECK(membershipChange.isBoundAfter1); + CHECK(membershipChange.moleculeType2 == system->getMoleculeTypeByName("B")); + CHECK(membershipChange.componentIndex2 == + system->getMoleculeTypeByName("B")->getCompIndexFromName("a")); + CHECK(membershipChange.isBoundAfter2); + int reactionCenterComponent = -1; + std::uint64_t contextComponentMask = 0; + unsigned int minimumContextComponents = 0; + REQUIRE(compactReaction->getCompactMembershipIndexInfo( + 0, reactionCenterComponent, contextComponentMask, + minimumContextComponents)); + CHECK(reactionCenterComponent == membershipChange.componentIndex1); + const int contextC = system->getMoleculeTypeByName("A") + ->getCompIndexFromName("c"); + const int contextD = system->getMoleculeTypeByName("A") + ->getCompIndexFromName("d"); + CHECK((contextComponentMask & (std::uint64_t(1) << contextC)) != 0); + CHECK((contextComponentMask & (std::uint64_t(1) << contextD)) != 0); + CHECK(minimumContextComponents >= 1); + auto* weightedMolecule = system->getMoleculeTypeByName("A")->getMolecule(0); + auto* partnerMolecule = system->getMoleculeTypeByName("B")->getMolecule(0); + auto* indirectMolecule = system->getMoleculeTypeByName("C")->getMolecule(0); + REQUIRE(weightedMolecule != nullptr); + REQUIRE(partnerMolecule != nullptr); + REQUIRE(indirectMolecule != nullptr); + CHECK(compactReaction->shouldUpdateMembership( + weightedMolecule, compactReaction, true)); + CHECK(compactReaction->shouldUpdateMembership( + partnerMolecule, compactReaction, true)); + CHECK_FALSE(compactReaction->shouldUpdateMembership( + indirectMolecule, compactReaction, true)); + CHECK_FALSE(compactReaction->shouldUpdateMembership( + weightedMolecule, compactReaction, false)); + CHECK(compactReaction->shouldUpdateMembershipForChange( + weightedMolecule, membershipChange)); + CHECK(compactReaction->shouldUpdateMembershipForChange( + partnerMolecule, membershipChange)); + CHECK_FALSE(compactReaction->shouldUpdateMembershipForChange( + indirectMolecule, membershipChange)); + NFcore::IncrementalMembershipChange contextChange = membershipChange; + contextChange.componentIndex1 = contextC; + contextChange.isBoundAfter1 = true; + CHECK(compactReaction->shouldUpdateMembershipForChange( + weightedMolecule, contextChange)); + + NFcore::Molecule* partialWeightedMolecule = nullptr; + for (int i = 0; + i < system->getMoleculeTypeByName("A")->getMoleculeCount(); ++i) { + auto* candidate = system->getMoleculeTypeByName("A")->getMolecule(i); + if (candidate->isBindingSiteBonded(contextC) && + candidate->isBindingSiteOpen(contextD)) { + partialWeightedMolecule = candidate; + break; + } + } + REQUIRE(partialWeightedMolecule != nullptr); + auto* partialContextMolecule = + partialWeightedMolecule->getBondedMolecule(contextC); + REQUIRE(partialContextMolecule != nullptr); + const int partialContextComponent = + partialWeightedMolecule->getBondedMoleculeBindingSiteIndex(contextC); + NFcore::Molecule::unbind(partialWeightedMolecule, contextC); + NFcore::IncrementalMembershipChange partialContextChange = membershipChange; + partialContextChange.componentIndex1 = contextC; + partialContextChange.isBoundAfter1 = false; + CHECK_FALSE(compactReaction->shouldUpdateMembershipForChange( + partialWeightedMolecule, partialContextChange)); + NFcore::Molecule::bind(partialWeightedMolecule, contextC, + partialContextMolecule, partialContextComponent); + + CHECK(compactReaction->canSkipIndirectMembership(compactReaction)); + CHECK(compactReaction->getCompactPartnerPool()->getRegisteredReactions().size() == 1); + CHECK(compactReaction->getCompactPartnerPool()->getRegisteredReactions().front() == compactReaction); + + system->prepareForSimulation(); + CHECK(compactReaction->getCompactPartnerPool()->size() == 1); + CHECK(system->getReaction(0)->get_a() == Catch::Approx(2.0 + std::exp(-0.5))); + CHECK(system->getReaction(1)->get_a() == Catch::Approx(0.0)); + + compactReaction->setUseRuleMonkey(true); + CHECK(compactReaction->update_a() == Catch::Approx(2.0 + std::exp(-0.5))); + compactReaction->setUseRuleMonkey(false); + compactReaction->fire(0.1); + CHECK_FALSE(system->isDeferringMembershipPropensityUpdates()); + int directWeightedCount = 0; + for (int i = 0; i < system->getMoleculeTypeByName("A")->getMoleculeCount(); ++i) + directWeightedCount += compactReaction->isDirectProductMolecule( + system->getMoleculeTypeByName("A")->getMolecule(i)) ? 1 : 0; + CHECK(directWeightedCount == 1); + CHECK(compactReaction->isDirectProductMolecule( + system->getMoleculeTypeByName("B")->getMolecule(0))); + for (int i = 0; i < system->getMoleculeTypeByName("C")->getMoleculeCount(); ++i) + CHECK_FALSE(compactReaction->isDirectProductMolecule( + system->getMoleculeTypeByName("C")->getMolecule(i))); + for (int i = 0; i < system->getMoleculeTypeByName("D")->getMoleculeCount(); ++i) + CHECK_FALSE(compactReaction->isDirectProductMolecule( + system->getMoleculeTypeByName("D")->getMolecule(i))); + delete system; +} + +TEST_CASE("NFsim AST adapter keeps one-way compact Arrhenius forward-only") { + // BNG2 expands the contextual one-way rule into forward variants only: + // the bound-context mapping contributes exp(-phi*Gcontext), while the + // context-free mapping contributes the activation factor of one. + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a) + C(x) +end molecule types +begin seed species + A(b,c!1).C(x!1) 1 + A(b) 1 + B(a) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) -> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 1); + auto* reaction = dynamic_cast(system->getReaction(0)); + REQUIRE(reaction != nullptr); + REQUIRE(reaction->getCompactPartnerPool() != nullptr); + CHECK(reaction->usesIncrementalMembership()); + system->prepareForSimulation(); + CHECK(reaction->getCompactPartnerPool()->size() == 1); + CHECK(reaction->get_a() == Catch::Approx(1.0 + std::exp(-0.5))); + CHECK(suggestedTraversalLimit >= 2); + delete system; +} + +TEST_CASE("NFsim compact energy partner memberships avoid mapping storage") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a) + C(x) +end molecule types +begin seed species + A(b,c!1).C(x!1) 1 + A(b) 1 + B(a) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + auto* aType = system->getMoleculeTypeByName("A"); + auto* bType = system->getMoleculeTypeByName("B"); + REQUIRE(aType != nullptr); + REQUIRE(bType != nullptr); + REQUIRE(aType->getReactionCount() == 2); + REQUIRE(bType->getReactionCount() == 1); + + CHECK(aType->getReactionMappingCount() == 2); + CHECK(aType->getReactionMappingIndex(0) >= 0); + CHECK(aType->getReactionMappingIndex(1) >= 0); + CHECK(bType->getReactionMappingCount() == 0); + CHECK(bType->getReactionMappingIndex(0) == -1); + + system->prepareForSimulation(); + auto* partner = bType->getMolecule(0); + REQUIRE(partner != nullptr); + CHECK(partner->getRxnListMappingId(0) == -1); + delete system; +} + +TEST_CASE("NFsim connectivity refresh clears direct endpoint identity") { + // Source-derived from akutuva21/nfsim commit 96be0b1: the reusable direct + // product scratch set must describe only the current firing. Two seeded + // eligible A molecules make a repeated connectivity-aware firing expose + // stale endpoint identity without inspecting private storage. + auto model = bng::parser::parseModel(R"BNG( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a) + C(x) +end molecule types +begin seed species + A(b,c!1).C(x!1) 1 + A(b) 1 + B(a) 2 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)BNG"); + REQUIRE(model != nullptr); + + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst( + *model, false, false, 100, false, suggestedTraversalLimit); + REQUIRE(system != nullptr); + auto* reaction = dynamic_cast( + system->getReaction(0)); + REQUIRE(reaction != nullptr); + auto* aType = system->getMoleculeTypeByName("A"); + REQUIRE(aType != nullptr); + REQUIRE(aType->getMoleculeCount() == 2); + + // The direct AST builder has no connectivity CLI switch yet; mirror the + // XML loader's post-construction flag propagation for this source oracle. + system->useConnectivityFlag(true); + for (auto* candidate : system->getAllReactions()) + candidate->setConnectivityFlag(true); + system->turnOff_OnTheFlyObs(); + system->prepareForSimulation(); + + auto* firstA = aType->getMolecule(0); + auto* secondA = aType->getMolecule(1); + REQUIRE(firstA != nullptr); + REQUIRE(secondA != nullptr); + + reaction->fire(0.0); + CHECK(reaction->isDirectProductMolecule(firstA)); + CHECK_FALSE(reaction->isDirectProductMolecule(secondA)); + + // Keep the oracle focused on direct-endpoint scratch lifetime. Refresh + // the changed molecule through the full membership path before the next + // event so the second fire is independently selected, even when a + // connectivity implementation defers that reaction's self-refresh. + aType->updateRxnMembership(firstA); + reaction->fire(0.999999); + CHECK_FALSE(reaction->isDirectProductMolecule(firstA)); + CHECK(reaction->isDirectProductMolecule(secondA)); + + delete system; +} + +TEST_CASE("NFsim compact energy caches multi-term factors") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Gc 1.0 + Gd 2.0 + RT 1.0 +end parameters +begin molecule types + A(b,c,d) + B(a) + C(x) + D(x) +end molecule types +begin seed species + A(b,c!1,d!2).C(x!1).D(x!2) 1 + A(b,c!3).C(x!3) 1 + A(b) 1 + B(a) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) Gc + A(b!1,d!2).B(a!1).D(x!2) Gd +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 2); + auto* compactReaction = + dynamic_cast(system->getReaction(0)); + REQUIRE(compactReaction != nullptr); + REQUIRE(compactReaction->usesIncrementalMembership()); + system->turnOff_OnTheFlyObs(); + REQUIRE(compactReaction->canUseDirectProductList()); + system->prepareForSimulation(); + CHECK(compactReaction->get_a() == Catch::Approx( + 1.0 + std::exp(-0.5) + std::exp(-1.5))); + delete system; +} + +TEST_CASE("NFsim compact energy refreshes rate factor after endpoint change") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a) + C(x) +end molecule types +begin seed species + A(b,c!1).C(x!1) 1 + A(b) 1 + B(a) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + auto* reaction = dynamic_cast( + system->getReaction(0)); + REQUIRE(reaction != nullptr); + auto* aType = system->getMoleculeTypeByName("A"); + REQUIRE(aType != nullptr); + auto* weighted = aType->getMolecule(0); + REQUIRE(weighted != nullptr); + + system->prepareForSimulation(); + const int reactionIndex = aType->getRxnIndex(reaction, 0); + const int mappingId = weighted->getRxnListMappingId(reactionIndex); + REQUIRE(mappingId >= 0); + CHECK(reaction->get_a() == Catch::Approx(1.0 + std::exp(-0.5))); + + const double oldA = reaction->get_a(); + NFcore::Molecule::unbind(weighted, aType->getCompIndexFromName("c")); + reaction->notifyRateFactorChange(weighted, 0, mappingId); + const double newA = reaction->update_a(); + system->update_A_tot(reaction, oldA, newA); + + CHECK(newA == Catch::Approx(2.0)); + CHECK(reaction->getCompactPartnerPoolCoefficient() == Catch::Approx(2.0)); + CHECK(reaction->get_a() == Catch::Approx(newA)); + delete system; +} + +TEST_CASE("NFsim compact reverse energy uses the weighted propensity factor") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a) + C(x) +end molecule types +begin seed species + A(b,c!1).C(x!1) 1 + B(a) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + REQUIRE(system->getAllReactions().size() == 2); + auto* forward = dynamic_cast( + system->getReaction(0)); + auto* reverse = dynamic_cast( + system->getReaction(1)); + REQUIRE(forward != nullptr); + REQUIRE(reverse != nullptr); + + system->turnOff_OnTheFlyObs(); + system->prepareForSimulation(); + CHECK(forward->get_a() == Catch::Approx(std::exp(-0.5))); + CHECK(reverse->get_a() == Catch::Approx(0.0)); + + forward->fire(0.0); + + CHECK(reverse->update_a() == Catch::Approx(std::exp(0.5))); + CHECK(reverse->get_a() == Catch::Approx(std::exp(0.5))); + delete system; +} + +TEST_CASE("NFsim compact partner refresh preserves mixed reaction memberships") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a,u) + C(x) + D(y) +end molecule types +begin seed species + A(b,c!1).C(x!1) 1 + A(b) 1 + B(a) 2 + C(x) 1 + D(y) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + B(u) + D(y) -> B(u!1).D(y!1) 1 +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + std::vector compactReactions; + NFcore::ReactionClass* ordinaryReaction = nullptr; + for (auto* reaction : system->getAllReactions()) { + auto* energyReaction = dynamic_cast(reaction); + if (energyReaction != nullptr && + energyReaction->getCompactPartnerPool() != nullptr) + compactReactions.push_back(energyReaction); + else if (energyReaction == nullptr) + ordinaryReaction = reaction; + } + REQUIRE(compactReactions.size() == 2); + REQUIRE(ordinaryReaction != nullptr); + + system->turnOff_OnTheFlyObs(); + system->prepareForSimulation(); + auto* bType = system->getMoleculeTypeByName("B"); + auto* cType = system->getMoleculeTypeByName("C"); + REQUIRE(bType != nullptr); + REQUIRE(cType != nullptr); + auto* partner = bType->getMolecule(0); + NFcore::Molecule* freeContext = nullptr; + const int contextComponent = cType->getCompIndexFromName("x"); + for (int i = 0; i < cType->getMoleculeCount(); ++i) { + auto* candidate = cType->getMolecule(i); + if (candidate->isBindingSiteOpen(contextComponent)) { + freeContext = candidate; + break; + } + } + REQUIRE(partner != nullptr); + REQUIRE(freeContext != nullptr); + REQUIRE(compactReactions.front()->getCompactPartnerPool() != nullptr); + CHECK(compactReactions.front()->getCompactPartnerPool()->size() == 2); + CHECK(compactReactions.back()->getCompactPartnerPool() == + compactReactions.front()->getCompactPartnerPool()); + CHECK(ordinaryReaction->get_a() == Catch::Approx(2.0)); + + const double ordinaryBefore = ordinaryReaction->get_a(); + NFcore::Molecule::bind(partner, "a", freeContext, "x"); + bType->updateRxnMembership(partner); + + CHECK(compactReactions.front()->getCompactPartnerPool()->size() == 1); + CHECK(compactReactions.back()->get_a() == Catch::Approx( + compactReactions.front()->get_a())); + CHECK(ordinaryReaction->get_a() == Catch::Approx(ordinaryBefore)); + delete system; +} + +TEST_CASE("NFsim compact energy stale binding is a null event") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a) + C(x) +end molecule types +begin seed species + A(b,c) 1 + B(a) 1 + C(x) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + auto* compactReaction = + dynamic_cast(system->getReaction(0)); + REQUIRE(compactReaction != nullptr); + system->turnOff_OnTheFlyObs(); + system->prepareForSimulation(); + + auto* aType = system->getMoleculeTypeByName("A"); + auto* bType = system->getMoleculeTypeByName("B"); + auto* cType = system->getMoleculeTypeByName("C"); + REQUIRE(aType != nullptr); + REQUIRE(bType != nullptr); + REQUIRE(cType != nullptr); + auto* a = aType->getMolecule(0); + auto* b = bType->getMolecule(0); + auto* c = cType->getMolecule(0); + REQUIRE(a != nullptr); + REQUIRE(b != nullptr); + REQUIRE(c != nullptr); + + /* Simulate a concurrent endpoint change after membership selection. The + * stale compact mapping must retain BindingTransform's null-event + * semantics and must not create an A-B bond. */ + const int aBindingComponent = aType->getCompIndexFromName("b"); + const int bBindingComponent = bType->getCompIndexFromName("a"); + NFcore::Molecule::bind(a, aBindingComponent, c, 0); + NFcore::System::NULL_EVENT_COUNTER = 0; + compactReaction->fire(0.0); + + CHECK(NFcore::System::NULL_EVENT_COUNTER == 1); + CHECK(a->getBondedMolecule(aBindingComponent) == c); + CHECK_FALSE(b->isBindingSiteBonded(bBindingComponent)); + CHECK_FALSE(a->getBondedMolecule(aBindingComponent) == b); + + NFcore::Molecule::unbind(a, aBindingComponent); + delete system; +} + +TEST_CASE("NFsim compact partner pool batches shared propensity changes") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c,d) + B(a) + C(x) + D(x) +end molecule types +begin seed species + A(b,c!1,d!2).C(x!1).D(x!2) 1 + A(b,c!3).C(x!3) 1 + A(b) 1 + B(a) 2 + C(x) 1 + D(x) 1 +end seed species +begin energy patterns + A(b!1,c!2,d!3).B(a!1).C(x!2).D(x!3) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + + std::vector compactReactions; + for (auto* reaction : system->getAllReactions()) { + auto* energyReaction = dynamic_cast(reaction); + if (energyReaction != nullptr && + energyReaction->getCompactPartnerPool() != nullptr) + compactReactions.push_back(energyReaction); + } + REQUIRE(compactReactions.size() == 2); + auto* pool = compactReactions.front()->getCompactPartnerPool(); + REQUIRE(pool != nullptr); + CHECK(compactReactions.back()->getCompactPartnerPool() == pool); + CHECK(pool->getRegisteredReactions().size() == 2); + + system->prepareForSimulation(); + REQUIRE(pool->size() == 2); + auto allReactions = system->getAllReactions(); + auto sumPropensities = [&]() { + double total = 0.0; + for (auto* reaction : system->getAllReactions()) + total += reaction->get_a(); + return total; + }; + + auto* bType = system->getMoleculeTypeByName("B"); + auto* cType = system->getMoleculeTypeByName("C"); + REQUIRE(bType != nullptr); + REQUIRE(cType != nullptr); + auto* partner = bType->getMolecule(0); + auto* context = static_cast(nullptr); + const int contextComponent = cType->getCompIndexFromName("x"); + for (int i = 0; i < cType->getMoleculeCount(); ++i) { + auto* candidate = cType->getMolecule(i); + if (candidate->isBindingSiteOpen(contextComponent)) { + context = candidate; + break; + } + } + REQUIRE(partner != nullptr); + REQUIRE(context != nullptr); + + const int deferredOldPoolSize = pool->size(); + const double deferredInitialPropensity = sumPropensities(); + system->beginDeferredMembershipPropensityUpdates(); + CHECK(system->isDeferringMembershipPropensityUpdates()); + NFcore::Molecule::bind(partner, "a", context, "x"); + bType->updateRxnMembership(partner); + CHECK(pool->size() == deferredOldPoolSize - 1); + system->endDeferredMembershipPropensityUpdates(); + CHECK_FALSE(system->isDeferringMembershipPropensityUpdates()); + CHECK(sumPropensities() == Catch::Approx( + deferredInitialPropensity * pool->size() / deferredOldPoolSize)); + + const int deferredReboundPoolSize = pool->size(); + NFcore::Molecule::unbind(partner, bType->getCompIndexFromName("a")); + system->beginDeferredMembershipPropensityUpdates(); + bType->updateRxnMembership(partner); + CHECK(pool->size() == deferredReboundPoolSize + 1); + system->endDeferredMembershipPropensityUpdates(); + CHECK_FALSE(system->isDeferringMembershipPropensityUpdates()); + CHECK(sumPropensities() == Catch::Approx(deferredInitialPropensity)); + + NFcore::DirectSelector selector(allReactions, system); + CHECK(selector.getAtot() == Catch::Approx(sumPropensities())); + + const int oldPoolSize = pool->size(); + NFcore::Molecule::bind(partner, "a", context, "x"); + bType->updateRxnMembership(partner); + + CHECK(pool->size() == 1); + selector.updateCompactPartnerPoolBatch( + pool->getRegisteredReactions(), oldPoolSize, pool->size(), 0); + CHECK(selector.getAtot() == Catch::Approx(sumPropensities())); + + const int reboundPoolSize = pool->size(); + const int partnerComponent = bType->getCompIndexFromName("a"); + NFcore::Molecule::unbind(partner, partnerComponent); + bType->updateRxnMembership(partner); + CHECK(pool->size() == 2); + selector.updateCompactPartnerPoolBatch( + pool->getRegisteredReactions(), reboundPoolSize, pool->size(), 0); + CHECK(selector.getAtot() == Catch::Approx(sumPropensities())); + + const int removedPoolSize = pool->size(); + bType->removeFromRxns(partner); + CHECK(pool->size() == 1); + selector.updateCompactPartnerPoolBatch( + pool->getRegisteredReactions(), removedPoolSize, pool->size(), 0); + CHECK(selector.getAtot() == Catch::Approx(sumPropensities())); + + auto* aType = system->getMoleculeTypeByName("A"); + REQUIRE(aType != nullptr); + auto* weighted = aType->getMolecule(0); + REQUIRE(weighted != nullptr); + const int weightedComponent = aType->getCompIndexFromName("b"); + REQUIRE(weighted->isBindingSiteOpen(weightedComponent)); + std::vector oldPropensities; + for (auto* reaction : allReactions) + oldPropensities.push_back(reaction->get_a()); + NFcore::Molecule::bind(weighted, weightedComponent, context, + contextComponent); + system->beginDeferredMembershipPropensityUpdates(); + aType->updateRxnMembership(weighted); + system->endDeferredMembershipPropensityUpdates(); + CHECK_FALSE(system->isDeferringMembershipPropensityUpdates()); + selector.updateBatch(allReactions, oldPropensities); + CHECK(selector.getAtot() == Catch::Approx(sumPropensities())); + + delete system; +} + +TEST_CASE("NFsim compact selector scales shared pools during selection") { + // Source-derived from NFsim 3b046fc: a DirectSelector may represent a + // factorized compact reaction as a weighted coefficient times the shared + // partner-pool size. Resizing that pool must update selection totals and + // the subsequent reaction draw without reevaluating every reaction. + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + G 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a) + C(x) +end molecule types +begin seed species + A(b,c!1).C(x!1) 1 + B(a) 2 + C(x) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) G +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + system->prepareForSimulation(); + + auto allReactions = system->getAllReactions(); + REQUIRE(allReactions.size() == 4); + std::vector forwardReactions; + NFcore::CompactPartnerPool* pool = nullptr; + for (auto* reaction : allReactions) { + auto* energyReaction = dynamic_cast(reaction); + REQUIRE(energyReaction != nullptr); + if (energyReaction->getCompactPartnerPool() == nullptr) + continue; + forwardReactions.push_back(energyReaction); + if (pool == nullptr) + pool = energyReaction->getCompactPartnerPool(); + CHECK(energyReaction->getCompactPartnerPool() == pool); + } + REQUIRE(forwardReactions.size() == 2); + REQUIRE(pool != nullptr); + REQUIRE(pool->size() == 2); + + NFcore::DirectSelector selector(allReactions, system); + const auto sumPropensities = [&]() { + double total = 0.0; + for (auto* reaction : allReactions) + total += reaction->get_a(); + return total; + }; + CHECK(selector.getAtot() == Catch::Approx(sumPropensities())); + + auto* bType = system->getMoleculeTypeByName("B"); + auto* cType = system->getMoleculeTypeByName("C"); + REQUIRE(bType != nullptr); + REQUIRE(cType != nullptr); + auto* partner = bType->getMolecule(0); + const int contextComponent = cType->getCompIndexFromName("x"); + NFcore::Molecule* context = nullptr; + for (int i = 0; i < cType->getMoleculeCount(); ++i) { + auto* candidate = cType->getMolecule(i); + if (candidate->isBindingSiteOpen(contextComponent)) { + context = candidate; + break; + } + } + REQUIRE(partner != nullptr); + REQUIRE(context != nullptr); + + const int oldPoolSize = pool->size(); + NFcore::Molecule::bind(partner, "a", context, "x"); + bType->updateRxnMembership(partner); + REQUIRE(pool->size() == oldPoolSize - 1); + selector.updateCompactPartnerPoolBatch( + pool->getRegisteredReactions(), oldPoolSize, pool->size(), 0); + + const double resizedTotal = sumPropensities(); + CHECK(selector.getAtot() == Catch::Approx(resizedTotal)); + REQUIRE(resizedTotal > 0.0); + + system->seedRNG(29); + NFcore::NfsimRNG expectedRng(29); + const double expectedDraw = expectedRng.random(resizedTotal); + double cumulative = 0.0; + std::size_t expectedIndex = allReactions.size(); + double previous = 0.0; + for (std::size_t index = 0; index < allReactions.size(); ++index) { + cumulative += allReactions[index]->get_a(); + if (expectedDraw <= cumulative) { + expectedIndex = index; + break; + } + previous = cumulative; + } + REQUIRE(expectedIndex < allReactions.size()); + + NFcore::ReactionClass* selected = nullptr; + const double residual = selector.getNextReactionClass(selected); + CHECK(selected == allReactions[expectedIndex]); + CHECK(residual == Catch::Approx(expectedDraw - previous)); + + NFcore::Molecule::unbind(partner, bType->getCompIndexFromName("a")); + delete system; +} + +TEST_CASE("NFsim compact selector preserves seeded order across membership refresh") { + const std::string modelText = R"( +begin parameters + phi 0.5 + Gcontext 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c,d) + B(a) + C(x) + D(x) +end molecule types +begin seed species + A(b,c!1,d!2).C(x!1).D(x!2) 1 + A(b,c!3).C(x!3) 1 + A(b) 1 + B(a) 2 + C(x) 1 + D(x) 1 +end seed species +begin energy patterns + A(b!1,c!2,d!3).B(a!1).C(x!2).D(x!3) Gcontext +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"; + + auto model = bng::parser::parseModel(modelText); + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + system->prepareForSimulation(); + + auto allReactions = system->getAllReactions(); + REQUIRE(allReactions.size() == 18); + for (auto* reaction : allReactions) { + auto* compactReaction = dynamic_cast(reaction); + REQUIRE(compactReaction != nullptr); + CHECK(compactReaction->supportsSparseSelection()); + } + + auto sumPropensities = [&]() { + double total = 0.0; + for (auto* reaction : allReactions) + total += reaction->get_a(); + return total; + }; + NFcore::DirectSelector selector(allReactions, system); + const double initialTotal = sumPropensities(); + CHECK(initialTotal > 0.0); + CHECK(selector.getAtot() == Catch::Approx(initialTotal)); + + system->seedRNG(17); + NFcore::NfsimRNG expectedInitialRng(17); + const double initialDraw = expectedInitialRng.random(initialTotal); + double expectedPrefix = 0.0; + double expectedPreviousPrefix = 0.0; + std::size_t expectedIndex = allReactions.size(); + for (std::size_t i = 0; i < allReactions.size(); ++i) { + expectedPrefix += allReactions[i]->get_a(); + if (initialDraw <= expectedPrefix) { + expectedIndex = i; + break; + } + expectedPreviousPrefix = expectedPrefix; + } + REQUIRE(expectedIndex < allReactions.size()); + NFcore::ReactionClass* selected = nullptr; + const double initialResidual = selector.getNextReactionClass(selected); + CHECK(selected == allReactions[expectedIndex]); + CHECK(initialResidual == Catch::Approx( + initialDraw - expectedPreviousPrefix)); + + std::vector oldPropensities; + for (auto* reaction : allReactions) + oldPropensities.push_back(reaction->get_a()); + auto* compactReaction = dynamic_cast( + allReactions.front()); + REQUIRE(compactReaction != nullptr); + REQUIRE(compactReaction->get_a() > 0.0); + compactReaction->fire(0.0); + + const double refreshedTotal = sumPropensities(); + REQUIRE(refreshedTotal > 0.0); + selector.updateBatch(allReactions, oldPropensities); + CHECK(selector.getAtot() == Catch::Approx(refreshedTotal)); + + system->seedRNG(19); + NFcore::NfsimRNG expectedRefreshedRng(19); + const double refreshedDraw = expectedRefreshedRng.random(refreshedTotal); + expectedPrefix = 0.0; + expectedPreviousPrefix = 0.0; + expectedIndex = allReactions.size(); + for (std::size_t i = 0; i < allReactions.size(); ++i) { + expectedPrefix += allReactions[i]->get_a(); + if (refreshedDraw <= expectedPrefix) { + expectedIndex = i; + break; + } + expectedPreviousPrefix = expectedPrefix; + } + REQUIRE(expectedIndex < allReactions.size()); + selected = nullptr; + const double refreshedResidual = selector.getNextReactionClass(selected); + CHECK(selected == allReactions[expectedIndex]); + CHECK(refreshedResidual == Catch::Approx( + refreshedDraw - expectedPreviousPrefix)); + + delete system; +} + +TEST_CASE("NFsim sparse selector reuses cached propensities in implicit batches") { + // Source-derived from NFsim 4bb24b3: an implicit sparse update batch must + // subtract the selector's cached pre-update propensity. Reading get_a() + // after the membership event has already changed it would subtract the + // new value and leave Atot stale. + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + G 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c,d) + B(a) + C(x) + D(x) +end molecule types +begin seed species + A(b,c!1,d!2).C(x!1).D(x!2) 1 + A(b,c!3).C(x!3) 1 + A(b) 1 + B(a) 2 + C(x) 1 + D(x) 1 +end seed species +begin energy patterns + A(b!1,c!2,d!3).B(a!1).C(x!2).D(x!3) G +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + system->prepareForSimulation(); + + auto allReactions = system->getAllReactions(); + REQUIRE(allReactions.size() == 18); + NFcore::EnergyRxnClass* compactReaction = nullptr; + for (auto* reaction : allReactions) { + auto* energyReaction = dynamic_cast(reaction); + REQUIRE(energyReaction != nullptr); + if (energyReaction->getCompactPartnerPool() != nullptr && + compactReaction == nullptr) + compactReaction = energyReaction; + CHECK(energyReaction->supportsSparseSelection()); + } + REQUIRE(compactReaction != nullptr); + + const auto sumPropensities = [&]() { + double total = 0.0; + for (auto* reaction : allReactions) + total += reaction->get_a(); + return total; + }; + NFcore::DirectSelector selector(allReactions, system); + const double initialTotal = sumPropensities(); + REQUIRE(initialTotal > 0.0); + CHECK(selector.getAtot() == Catch::Approx(initialTotal)); + + compactReaction->fire(0.0); + const double refreshedTotal = sumPropensities(); + REQUIRE(refreshedTotal != Catch::Approx(initialTotal)); + selector.updateBatch(allReactions); + CHECK(selector.getAtot() == Catch::Approx(refreshedTotal)); + + delete system; +} + +TEST_CASE("NFsim compact membership refresh preserves unrelated partner rules") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + G 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a,u,v,w) + C(x) +end molecule types +begin seed species + A(b,c!1).C(x!1) 4 + B(a,u,v,w) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) G + A(b!1,c!2).B(u!1).C(x!2) G + A(b!1,c!2).B(v!1).C(x!2) G + A(b!1,c!2).B(w!1).C(x!2) G +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(u) <-> A(b!1).B(u!1) Arrhenius(phi,0) + A(b) + B(v) <-> A(b!1).B(v!1) Arrhenius(phi,0) + A(b) + B(w) <-> A(b!1).B(w!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + const auto allReactions = system->getAllReactions(); + REQUIRE(allReactions.size() == 8); + + std::vector forwardReactions; + std::vector reverseReactions; + for (auto* reaction : allReactions) { + auto* energyReaction = dynamic_cast(reaction); + REQUIRE(energyReaction != nullptr); + CHECK(energyReaction->usesIncrementalMembership()); + CHECK(energyReaction->membershipDecisionIsTypeInvariant()); + if (energyReaction->getCompactPartnerPool() != nullptr) + forwardReactions.push_back(energyReaction); + else + reverseReactions.push_back(energyReaction); + } + REQUIRE(forwardReactions.size() == 4); + REQUIRE(reverseReactions.size() == 4); + + system->turnOff_OnTheFlyObs(); + system->prepareForSimulation(); + std::vector initialForwardPropensities; + for (auto* reaction : forwardReactions) { + REQUIRE(reaction->get_a() > 0.0); + initialForwardPropensities.push_back(reaction->get_a()); + } + for (auto* reaction : reverseReactions) + CHECK(reaction->get_a() == Catch::Approx(0.0)); + + auto* firedReaction = forwardReactions.front(); + firedReaction->fire(0.0); + + CHECK(firedReaction->get_a() == Catch::Approx(0.0)); + for (std::size_t i = 1; i < forwardReactions.size(); ++i) + CHECK(forwardReactions[i]->get_a() == Catch::Approx( + initialForwardPropensities[i] * 0.75)); + int activeReverseCount = 0; + for (auto* reaction : reverseReactions) { + if (reaction->get_a() > 0.0) + ++activeReverseCount; + } + CHECK(activeReverseCount == 1); + + delete system; +} + +TEST_CASE("NFsim compact pool refresh preserves all-forward reaction state") { + auto model = bng::parser::parseModel(R"( +begin parameters + phi 0.5 + G 1.0 + RT 1.0 +end parameters +begin molecule types + A(b,c) + B(a) + C(x) +end molecule types +begin seed species + A(b,c!1).C(x!1) 1 + B(a) 1 +end seed species +begin energy patterns + A(b!1,c!2).B(a!1).C(x!2) G +end energy patterns +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) + A(b) + B(a) <-> A(b!1).B(a!1) Arrhenius(phi,0) +end reaction rules +)"); + + REQUIRE(model != nullptr); + int suggestedTraversalLimit = 0; + auto* system = NFinput::buildSystemFromAst(*model, false, 100, false, + suggestedTraversalLimit); + REQUIRE(system != nullptr); + const auto allReactions = system->getAllReactions(); + REQUIRE(allReactions.size() == 4); + + std::vector forwardReactions; + NFcore::CompactPartnerPool* pool = nullptr; + for (auto* reaction : allReactions) { + auto* energyReaction = dynamic_cast(reaction); + REQUIRE(energyReaction != nullptr); + if (energyReaction->getCompactPartnerPool() == nullptr) + continue; + forwardReactions.push_back(energyReaction); + if (pool == nullptr) + pool = energyReaction->getCompactPartnerPool(); + CHECK(energyReaction->getCompactPartnerPool() == pool); + } + REQUIRE(forwardReactions.size() == 2); + REQUIRE(pool != nullptr); + CHECK(pool->getRegisteredReactions().size() == forwardReactions.size()); + + system->prepareForSimulation(); + REQUIRE(pool->size() == 1); + for (auto* reaction : forwardReactions) + REQUIRE(reaction->get_a() > 0.0); + + forwardReactions.front()->fire(0.0); + + CHECK(pool->size() == 0); + for (auto* reaction : forwardReactions) + CHECK(reaction->get_a() == Catch::Approx(0.0)); + + delete system; +} + +TEST_CASE("NFsim connectivity preserves the seeded t3 trajectory") { + // Source-derived regression oracle: nfsim/test/testSuite/t3.bngl and the + // connected-update fix in nfsim commit 23436e2. The upstream validation + // runs the same XML model with and without -connect and requires bytewise + // identical observable trajectories for seed 1. + auto model = bng::parser::parseModel(R"BNG( +begin parameters + kr 7 + kb 20 + kCat 10 + kDephos 10 + ReceptorComplexCount 4000 +end parameters +begin seed species + Receptor(m~3,a!1).CheA(rec!1,p~unphos) ReceptorComplexCount +end seed species +begin observables + Molecules R0 Receptor(m~0) + Molecules R1 Receptor(m~1) + Molecules R2 Receptor(m~2) + Molecules R3 Receptor(m~3) + Molecules R4 Receptor(m~4) + Molecules RA Receptor(a!1).CheA(rec!1,p~unphos) + Molecules CheA CheA(p~unphos) + Molecules CheAp CheA(p~PHOS) +end observables +begin functions + pOn(x) = kCat*(R1(x)+2*R2(x)+3*R3(x)+4*R4(x)) +end functions +begin reaction rules + Receptor(m~0) <-> Receptor(m~1) kr,kb + Receptor(m~1) <-> Receptor(m~2) kr,kb + Receptor(m~2) <-> Receptor(m~3) kr,kb + Receptor(m~3) <-> Receptor(m~4) kr,kb + %x::Receptor(a!1).CheA(rec!1,p~unphos) -> %x::Receptor(a!1).CheA(rec!1,p~PHOS) pOn(x) + CheA(p~PHOS) -> CheA(p~unphos) kDephos +end reaction rules +)BNG"); + REQUIRE(model != nullptr); + + const auto xml = bng::io::XmlWriter::write(*model); + const auto token = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto xmlPath = std::filesystem::temp_directory_path() / + ("bng3-t3-connectivity-" + std::to_string(token) + ".xml"); + std::ofstream xmlFile(xmlPath); + REQUIRE(xmlFile.good()); + xmlFile << xml; + xmlFile.close(); + + auto run = [&](bool connectivity, const std::filesystem::path& outputPath) { + int suggestedTraversalLimit = NFcore::ReactionClass::NO_LIMIT; + auto* system = NFinput::initializeFromXML( + xmlPath.string(), false, 100000, false, suggestedTraversalLimit, + true, connectivity); + REQUIRE(system != nullptr); + system->setUniversalTraversalLimit(suggestedTraversalLimit); + system->registerOutputFileLocation(outputPath.string()); + system->outputAllObservableNames(); + system->prepareForSimulation(); + system->seedRNG(1); + system->sim(0.02, 5, false); + delete system; + }; + + const auto offPath = std::filesystem::temp_directory_path() / + ("bng3-t3-off-" + std::to_string(token) + ".gdat"); + const auto onPath = std::filesystem::temp_directory_path() / + ("bng3-t3-on-" + std::to_string(token) + ".gdat"); + run(false, offPath); + run(true, onPath); + + std::ifstream offFile(offPath); + std::ifstream onFile(onPath); + REQUIRE(offFile.good()); + REQUIRE(onFile.good()); + const std::string offData((std::istreambuf_iterator(offFile)), + std::istreambuf_iterator()); + const std::string onData((std::istreambuf_iterator(onFile)), + std::istreambuf_iterator()); + CHECK(offData == onData); + + std::error_code error; + std::filesystem::remove(xmlPath, error); + std::filesystem::remove(offPath, error); + std::filesystem::remove(onPath, error); +} + +TEST_CASE("NFsim XML bridge preserves functional symmetry around TotalRate") { + // Source-derived from akutuva21/nfsim commit 1b19611 and its + // test/symmetry/symmetry_factor_total_rate fixture. XML carries the + // BNG2-emitted reaction-center symmetry factor; ordinary functional rates + // must apply it, while TotalRate must use the function value unchanged. + auto model = bng::parser::parseModel(R"BNG( +begin parameters + k 1 +end parameters +begin molecule types + A(b) + B(b) + Source() +end molecule types +begin seed species + A(b!1).A(b!1) 1 + B(b!1).B(b!1) 1 + Source() 1 +end seed species +begin observables + Molecules SourceCount Source() +end observables +begin functions + rate() = k*SourceCount +end functions +begin reaction rules + ordinary: A(b!1).A(b!1) -> 0 rate DeleteMolecules + total: B(b!1).B(b!1) -> 0 rate TotalRate DeleteMolecules +end reaction rules +)BNG"); + REQUIRE(model != nullptr); + + std::string xml = bng::io::XmlWriter::write(*model); + std::size_t searchPosition = 0; + for (int ruleIndex = 0; ruleIndex < 2; ++ruleIndex) { + const auto rulePosition = xml.find("getAllReactions().size() == 2); + system->prepareForSimulation(); + + CHECK(system->getReaction(0)->get_a() == Catch::Approx(1.0)); + CHECK(system->getReaction(1)->get_a() == Catch::Approx(1.0)); + + delete system; + std::error_code error; + std::filesystem::remove(xmlPath, error); +} diff --git a/tests/cpp/test_nfsim_reactant_tree.cpp b/tests/cpp/test_nfsim_reactant_tree.cpp new file mode 100644 index 00000000..4e6a466a --- /dev/null +++ b/tests/cpp/test_nfsim_reactant_tree.cpp @@ -0,0 +1,366 @@ +#include +#include +#include +#include + +#include +#include +#include + +#include "NFcore.hh" +#include "NFinput/NFinput_fromAst.hh" +#include "NFreactions/reactantLists/reactantTree.hh" +#include "NFreactions/transformations/transformationSet.hh" +#include "parser/BNGAstVisitor.hpp" + +TEST_CASE("NFsim System rejects unsafe output names") { + // Source-derived from NFsim commit 3527edb and its System constructor + // regression test. System names become output-file prefixes, so all + // constructor overloads must reject path-like input. + const std::vector invalidNames { + "../escape", "/absolute", "C:\\escape", "C:escape", + "nested/name", "nested\\name", "contains..dots" + }; + + for (const auto& name : invalidNames) { + for (int constructor = 0; constructor < 3; ++constructor) { + const auto construct = [&name, constructor]() { + if (constructor == 0) { + NFcore::System system(name); + } else if (constructor == 1) { + NFcore::System system(name, true); + } else { + NFcore::System system(name, true, 100); + } + }; + CHECK_THROWS_WITH(construct(), + "Path traversal detected in System name."); + } + } +} + +TEST_CASE("NFsim stepTo preserves continuous simulation checkpoints") { + // Source-derived from NFsim commit e3ef4a0 and + // validate/basicModels/step_to_cache.xml. A pending event that falls + // after an output boundary must be retained, so chunked stepTo calls and + // one continuous sim consume the same event stream. + auto model = bng::parser::parseModel(R"BNG( +begin parameters + k 10 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() 100 +end seed species +begin observables + Molecules B_total B() +end observables +begin reaction rules + A() -> A() + B() k +end reaction rules +)BNG"); + REQUIRE(model != nullptr); + + int continuousTraversalLimit = 0; + auto* continuous = NFinput::buildSystemFromAst( + *model, false, 20000, false, continuousTraversalLimit); + REQUIRE(continuous != nullptr); + continuous->seedRNG(1); + continuous->prepareForSimulation(); + continuous->getOutputFileStream().setUseFile(false); + continuous->sim(10.0, 10, false); + + std::vector continuousTimes; + std::vector continuousCounts; + std::istringstream continuousOutput(continuous->getOutputFileStream().str()); + std::string outputLine; + while (std::getline(continuousOutput, outputLine)) { + if (outputLine.empty() || outputLine.front() == '#') continue; + std::istringstream row(outputLine); + double time = 0.0; + double count = 0.0; + if (row >> time >> count) { + continuousTimes.push_back(time); + continuousCounts.push_back(count); + } + } + REQUIRE(continuousTimes.size() == 11); + REQUIRE(continuousCounts.size() == 11); + + int chunkedTraversalLimit = 0; + auto* chunked = NFinput::buildSystemFromAst( + *model, false, 20000, false, chunkedTraversalLimit); + REQUIRE(chunked != nullptr); + chunked->seedRNG(1); + chunked->prepareForSimulation(); + std::vector chunkedCounts; + for (int checkpoint = 1; checkpoint <= 10; ++checkpoint) { + CHECK(chunked->stepTo(static_cast(checkpoint)) == + Catch::Approx(static_cast(checkpoint))); + chunkedCounts.push_back( + chunked->getObservableByName("B_total")->getCount()); + } + + CHECK(chunked->getCurrentTime() == Catch::Approx(10.0)); + REQUIRE(chunkedCounts.size() == 10); + for (std::size_t index = 0; index < chunkedCounts.size(); ++index) { + CHECK(continuousTimes[index + 1] == + Catch::Approx(static_cast(index + 1))); + CHECK(chunkedCounts[index] == + static_cast(continuousCounts[index + 1])); + } + + delete chunked; + delete continuous; +} + +TEST_CASE("NFsim stepTo advances zero-propensity checkpoints") { + // Source-derived from NFsim commit e3ef4a0 and + // validate/basicModels/step_to_zero_propensity.xml. A system with no + // available reactants must still advance each requested output boundary. + auto model = bng::parser::parseModel(R"BNG( +begin parameters + k 1 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() 0 + B() 1 +end seed species +begin observables + Molecules B_total B() +end observables +begin reaction rules + A() -> A() + B() k +end reaction rules +)BNG"); + REQUIRE(model != nullptr); + + int continuousTraversalLimit = 0; + auto* continuous = NFinput::buildSystemFromAst( + *model, false, 100, false, continuousTraversalLimit); + REQUIRE(continuous != nullptr); + continuous->prepareForSimulation(); + continuous->getOutputFileStream().setUseFile(false); + continuous->sim(2.0, 2, false); + + int chunkedTraversalLimit = 0; + auto* chunked = NFinput::buildSystemFromAst( + *model, false, 100, false, chunkedTraversalLimit); + REQUIRE(chunked != nullptr); + chunked->prepareForSimulation(); + CHECK(chunked->stepTo(1.0) == Catch::Approx(1.0)); + CHECK(chunked->stepTo(2.0) == Catch::Approx(2.0)); + CHECK(chunked->getObservableByName("B_total")->getCount() == 1); + + std::vector continuousTimes; + std::vector continuousCounts; + std::istringstream continuousOutput(continuous->getOutputFileStream().str()); + std::string outputLine; + while (std::getline(continuousOutput, outputLine)) { + if (outputLine.empty() || outputLine.front() == '#') continue; + std::istringstream row(outputLine); + double time = 0.0; + double count = 0.0; + if (row >> time >> count) { + continuousTimes.push_back(time); + continuousCounts.push_back(count); + } + } + REQUIRE(continuousTimes.size() == 3); + REQUIRE(continuousCounts.size() == 3); + CHECK(continuousTimes[1] == Catch::Approx(1.0)); + CHECK(continuousTimes[2] == Catch::Approx(2.0)); + CHECK(continuousCounts[0] == 1.0); + CHECK(continuousCounts[1] == 1.0); + CHECK(continuousCounts[2] == 1.0); + + delete chunked; + delete continuous; +} + +TEST_CASE("NFsim ReactantTree preserves the compact one-leaf contract") { + NFcore::System system("ReactantTree contract"); + std::vector componentNames {"site"}; + auto* moleculeType = new NFcore::MoleculeType( + "TreeTest", componentNames, &system); + auto* templateMolecule = new NFcore::TemplateMolecule(moleculeType); + std::vector templates {templateMolecule}; + NFcore::TransformationSet transformations(templates); + transformations.finalize(); + + NFcore::ReactantTree tree(0, &transformations, 1); + CHECK(tree.getDepthOfTree() == 0); + + auto* first = tree.pushNextAvailableMappingSet(); + REQUIRE(first != nullptr); + const unsigned int firstId = first->getId(); + tree.confirmPush(firstId, 2.0); + CHECK(tree.size() == 1); + CHECK(tree.getRateFactorSum() == 2.0); + CHECK(tree.getRateFactor(0) == 2.0); + + NFcore::MappingSet* picked = nullptr; + tree.pickReactantFromValue(picked, 1.0, 1.0); + CHECK(picked == first); + + CHECK(tree.updateValue(firstId, 3.0)); + CHECK_FALSE(tree.updateValue(firstId, 3.0)); + tree.pickReactantFromValue(picked, 2.0, 1.0); + CHECK(tree.getRateFactorSum() == 3.0); + CHECK(tree.getRateFactor(0) == 3.0); + CHECK(picked == first); + + auto* second = tree.pushNextAvailableMappingSet(); + REQUIRE(second != nullptr); + const unsigned int secondId = second->getId(); + tree.confirmPush(secondId, 5.0); + tree.pickReactantFromValue(picked, 4.0, 1.0); + CHECK(tree.size() == 2); + CHECK(tree.getRateFactorSum() == 8.0); + CHECK(picked == second); + + tree.removeMappingSet(secondId); + tree.pickReactantFromValue(picked, 2.0, 1.0); + CHECK(tree.size() == 1); + CHECK(tree.getRateFactorSum() == 3.0); + CHECK(tree.getRateFactor(0) == 3.0); + CHECK(picked == first); + + tree.removeMappingSet(firstId); + CHECK(tree.size() == 0); + CHECK(tree.getRateFactorSum() == 0.0); +} + +TEST_CASE("NFsim ReactantTree preserves weights across repeated expansion") { + /* Source-derived from ReactantTree::expandTree: active MappingSets are + * reinserted by stable id when the complete tree grows, then cleared + * slots are reused without losing weighted selection state. */ + NFcore::System system("ReactantTree expansion contract"); + std::vector componentNames {"site"}; + auto* moleculeType = new NFcore::MoleculeType( + "ExpansionTree", componentNames, &system); + auto* templateMolecule = new NFcore::TemplateMolecule(moleculeType); + std::vector templates {templateMolecule}; + NFcore::TransformationSet transformations(templates); + transformations.finalize(); + + NFcore::ReactantTree tree(0, &transformations, 1); + const std::vector rates {2.0, 5.0, 7.0, 11.0, 13.0}; + std::map expected; + + for (const double rate : rates) { + auto* mapping = tree.pushNextAvailableMappingSet(); + REQUIRE(mapping != nullptr); + expected.emplace(mapping->getId(), rate); + tree.confirmPush(mapping->getId(), rate); + } + + CHECK(tree.getDepthOfTree() == 3); + CHECK(tree.size() == static_cast(rates.size())); + CHECK(tree.getRateFactorSum() == 38.0); + for (int index = 0; index < tree.size(); ++index) { + auto* mapping = tree.getMappingSetByIndex(static_cast(index)); + REQUIRE(mapping != nullptr); + CHECK(tree.getRateFactor(index) == expected.at(mapping->getId())); + } + + tree.removeMappingSet(1); + expected.erase(1); + CHECK(tree.size() == 4); + CHECK(tree.getRateFactorSum() == 33.0); + for (int index = 0; index < tree.size(); ++index) { + auto* mapping = tree.getMappingSetByIndex(static_cast(index)); + REQUIRE(mapping != nullptr); + CHECK(tree.getRateFactor(index) == expected.at(mapping->getId())); + } + + auto* recycled = tree.pushNextAvailableMappingSet(); + REQUIRE(recycled != nullptr); + const unsigned int recycledId = recycled->getId(); + tree.confirmPush(recycledId, 17.0); + expected[recycledId] = 17.0; + CHECK(tree.size() == 5); + CHECK(tree.getRateFactorSum() == 50.0); + for (int index = 0; index < tree.size(); ++index) { + auto* mapping = tree.getMappingSetByIndex(static_cast(index)); + REQUIRE(mapping != nullptr); + CHECK(tree.getRateFactor(index) == expected.at(mapping->getId())); + } +} + +TEST_CASE("NFsim CompactPartnerPool preserves source swap-removal indexing") { + NFcore::System system("CompactPartnerPool contract"); + std::vector componentNames {"site"}; + auto* moleculeType = new NFcore::MoleculeType( + "PoolMolecule", componentNames, &system); + auto* first = moleculeType->genDefaultMolecule(); + auto* second = moleculeType->genDefaultMolecule(); + auto* third = moleculeType->genDefaultMolecule(); + + NFcore::CompactPartnerPool pool; + const auto firstId = static_cast(first->getMolListId()); + const auto secondId = static_cast(second->getMolListId()); + const auto thirdId = static_cast(third->getMolListId()); + + CHECK(pool.size() == 0); + CHECK(pool.add(first, firstId)); + CHECK(pool.add(second, secondId)); + CHECK(pool.add(third, thirdId)); + CHECK_FALSE(pool.add(first, firstId)); + + CHECK(pool.remove(first, firstId)); + CHECK_FALSE(pool.contains(first, firstId)); + CHECK(pool.contains(second, secondId)); + CHECK(pool.contains(third, thirdId)); + CHECK(pool.getByIndex(0) == third); + CHECK(pool.size() == 2); +} + +TEST_CASE("NFsim MoleculeType retains pooled molecules across bulk removal") { + /* Source-derived from MoleculeList ownership: removeAllMolecules removes + * live entries from the fixed-capacity list, while MoleculeList remains + * responsible for deleting every preallocated object at destruction. */ + NFcore::System system("MoleculeType bulk removal contract"); + std::vector componentNames {"site"}; + auto* moleculeType = new NFcore::MoleculeType( + "BulkRemoval", componentNames, &system); + + auto* first = moleculeType->genDefaultMolecule(); + auto* second = moleculeType->genDefaultMolecule(); + REQUIRE(first != nullptr); + REQUIRE(second != nullptr); + CHECK(moleculeType->getMoleculeCount() == 2); + + moleculeType->removeAllMolecules(); + CHECK(moleculeType->getMoleculeCount() == 0); + + auto* recycled = moleculeType->genDefaultMolecule(); + REQUIRE(recycled != nullptr); + CHECK(moleculeType->getMoleculeCount() == 1); + CHECK(recycled == first); +} + +TEST_CASE("NFsim MoleculeType exposes one compact pool per component") { + NFcore::System system("MoleculeType compact pool contract"); + std::vector componentNames {"left", "right"}; + auto* moleculeType = new NFcore::MoleculeType( + "PoolOwner", componentNames, &system); + + auto* left = moleculeType->getOrCreateCompactPartnerPool(0); + auto* leftAgain = moleculeType->getOrCreateCompactPartnerPool(0); + auto* right = moleculeType->getOrCreateCompactPartnerPool(1); + + REQUIRE(left != nullptr); + CHECK(leftAgain == left); + REQUIRE(right != nullptr); + CHECK(right != left); + CHECK(moleculeType->getOrCreateCompactPartnerPool(-1) == nullptr); + CHECK(moleculeType->getOrCreateCompactPartnerPool(2) == nullptr); +} diff --git a/tests/cpp/test_node_operator.cpp b/tests/cpp/test_node_operator.cpp index 276adc37..636bbd3c 100644 --- a/tests/cpp/test_node_operator.cpp +++ b/tests/cpp/test_node_operator.cpp @@ -88,3 +88,37 @@ TEST_CASE("Node::operator==", "[BNGcore][Node]") { REQUIRE_FALSE(node1 == node2); } } + +TEST_CASE("PatternGraph canonicalization defines the empty graph", "[BNGcore][PatternGraph]") { + // Source-derived from akutuva21/bionetgen commit 46da45c4: empty + // SpeciesGraph values must not index ptn[-1] during canonicalization. + PatternGraph graph; + + REQUIRE(graph.get_label().empty()); + REQUIRE(graph.is_canonical()); +} + +TEST_CASE("Node canonical labels retain their exact format", "[BNGcore][Node]") { + // Source-derived from akutuva21/bionetgen commit b73d9e3d: the + // allocation reduction must preserve canonical label bytes. + NodeType type("A", ENTITY_NODE_TYPE, NULL_STATE_TYPE); + Node node(type); + node.set_index(7); + + REQUIRE(node.get_label() == "7:A~<0>"); +} + +TEST_CASE("PatternGraph BNG2 strings retain their exact format", "[BNGcore][PatternGraph]") { + // Source-derived from akutuva21/bionetgen commit 556099d3: replacing + // string streams with append-based assembly must not change .net text. + PatternGraph graph; + NodeType typeA("A", ENTITY_NODE_TYPE, NULL_STATE_TYPE); + NodeType typeB("B", ENTITY_NODE_TYPE, NULL_STATE_TYPE); + Node nodeA(typeA); + Node nodeB(typeB); + Node* graphNodeA = graph.add_node(nodeA); + Node* graphNodeB = graph.add_node(nodeB); + graph.add_edge(graphNodeA, graphNodeB); + + REQUIRE(graph.get_BNG2_string() == "A(B)"); +} diff --git a/tests/cpp/test_ode_options.cpp b/tests/cpp/test_ode_options.cpp new file mode 100644 index 00000000..9e61e4c1 --- /dev/null +++ b/tests/cpp/test_ode_options.cpp @@ -0,0 +1,301 @@ +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "engine/NetworkGenerator.hpp" +#include "engine/OdeIntegrator.hpp" +#include "io/NetWriter.hpp" +#include "parser/BNGAstVisitor.hpp" + +using namespace bng; + +namespace { + +std::unique_ptr parseDecayModel() { + return parser::parseModel(R"( +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 100 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +)"); +} + +} // namespace + +TEST_CASE("OdeIntegrator honors explicit nonuniform sample times", "[OdeOptions]") { + auto model = parseDecayModel(); + engine::NetworkGenerator generator(*model); + auto network = generator.generateNative(); + + engine::OdeOptions options; + options.method = "cvode"; + options.tStart = 0.0; + options.tEnd = 10.0; + options.nSteps = 1; + options.sampleTimes = {0.0, 0.25, 1.5, 10.0}; + + const auto result = engine::OdeIntegrator(*model, network).integrate(options); + + REQUIRE(result.timePoints == options.sampleTimes); + REQUIRE(result.concentrations.size() == options.sampleTimes.size()); + REQUIRE(result.observables.size() == options.sampleTimes.size()); +} + +TEST_CASE("OdeIntegrator rejects malformed stop conditions", "[OdeOptions]") { + auto model = parseDecayModel(); + engine::NetworkGenerator generator(*model); + auto network = generator.generateNative(); + + engine::OdeOptions options; + options.method = "cvode"; + options.tEnd = 1.0; + options.nSteps = 2; + options.stopIf = "Xtot <"; + + REQUIRE_THROWS_WITH( + engine::OdeIntegrator(*model, network).integrate(options), + Catch::Matchers::ContainsSubstring("stop_if")); +} + +TEST_CASE("OdeIntegrator evaluates user-defined function rates", "[OdeOptions]") { + // Source-derived from akutuva21/bionetgen PR #508 head e67850cf and + // PR #509 head 5cf5cd47: function-name matching is an allocation-sensitive + // compile path, but must retain the user-defined rate contract. + auto model = parser::parseModel(R"( +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin functions + rate() = 2 +end functions +begin reaction rules + X() -> 0 rate +end reaction rules +)"); + + engine::NetworkGenerator generator(*model); + const auto network = generator.generateNative(); + engine::OdeIntegrator integrator(*model, network); + + double state[] = {1.0}; + double derivatives[] = {0.0}; + integrator.derivs(0.0, state, derivatives); + + REQUIRE_THAT(derivatives[0], Catch::Matchers::WithinAbs(-2.0, 1e-12)); +} + +TEST_CASE("OdeIntegrator preserves case-insensitive rate classification", "[OdeOptions]") { + // Source-derived from akutuva21/bionetgen commit + // 5fab87788a4d6253ea83fd2cb35312be0c99c725: caching the lowercased raw + // rate law and suppressing a duplicate function scan must not change the + // existing classification contract. + auto makeNetwork = [](const std::string& rateLaw) { + engine::GeneratedNetwork network; + network.species.setCheckIso(false); + + ast::SpeciesGraph reactantGraph; + network.species.add(ast::Species(reactantGraph, 1.0)); + + ast::SpeciesGraph productGraph; + network.species.add(ast::Species(productGraph, 0.0)); + + network.reactions.add(ast::Rxn( + "R1", {0}, {1}, rateLaw, 1.0, "dummy_rule", + ast::Expression::number(2.0))); + return network; + }; + + SECTION("time keyword casing remains functional") { + for (const auto& rateLaw : {std::string("time"), std::string("TIME"), + std::string("Time")}) { + ast::Model model; + auto network = makeNetwork(rateLaw); + engine::OdeIntegrator integrator(model, network); + + double state[] = {1.0, 0.0}; + double derivatives[] = {0.0, 0.0}; + integrator.derivs(0.0, state, derivatives); + + REQUIRE_THAT(derivatives[0], Catch::Matchers::WithinAbs(-2.0, 1e-12)); + REQUIRE_THAT(derivatives[1], Catch::Matchers::WithinAbs(2.0, 1e-12)); + } + } + + SECTION("mixed-case function names are matched without lowercasing") { + ast::Model model; + model.addFunction(ast::Function( + "rateFn", {}, ast::Expression::number(1.0))); + auto network = makeNetwork("RATEFN"); + engine::OdeIntegrator integrator(model, network); + + double state[] = {1.0, 0.0}; + double derivatives[] = {0.0, 0.0}; + integrator.derivs(0.0, state, derivatives); + + REQUIRE_THAT(derivatives[0], Catch::Matchers::WithinAbs(-2.0, 1e-12)); + REQUIRE_THAT(derivatives[1], Catch::Matchers::WithinAbs(2.0, 1e-12)); + } +} + +TEST_CASE("Observable pattern compilation preserves multi-pattern weights", "[OdeOptions]") { + // Source-derived from akutuva21/bionetgen commit 60ac7e5f: moving + // observable parsing outside the species loop must preserve every pattern + // contribution when a functional ODE rate reads the group. + auto model = parser::parseModel(R"( +begin molecule types + A() + B() +end molecule types +begin seed species + A() 2 + B() 1 +end seed species +begin observables + Molecules total A() B() +end observables +begin reaction rules + A() -> B() total +end reaction rules +)"); + + engine::NetworkGenerator generator(*model); + const auto network = generator.generateNative(); + engine::OdeIntegrator integrator(*model, network); + + double state[] = {2.0, 1.0}; + double derivatives[] = {0.0, 0.0}; + integrator.derivs(0.0, state, derivatives); + + REQUIRE_THAT(derivatives[0], Catch::Matchers::WithinAbs(-6.0, 1e-12)); + REQUIRE_THAT(derivatives[1], Catch::Matchers::WithinAbs(6.0, 1e-12)); +} + +TEST_CASE("NetWriter preserves repeated observable group patterns", "[NetWriter]") { + // Source-derived from akutuva21/bionetgen commit 60ac7e5f: pre-parsing + // each observable pattern must leave the emitted group entries unchanged. + auto model = parser::parseModel(R"( +begin molecule types + A() + B() +end molecule types +begin seed species + A() 2 + B() 1 +end seed species +begin observables + Molecules total A() B() + Species present A() +end observables +begin reaction rules + A() -> B() 1 +end reaction rules +)"); + + engine::NetworkGenerator generator(*model); + const auto network = generator.generateNative(); + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + const auto outputPath = std::filesystem::temp_directory_path() / + ("bng3-net-writer-groups-" + std::to_string(suffix) + ".net"); + io::NetWriter::write(outputPath, *model, network); + + std::ifstream input(outputPath); + REQUIRE(input.good()); + const std::string output((std::istreambuf_iterator(input)), std::istreambuf_iterator()); + std::filesystem::remove(outputPath); + + REQUIRE(output.find("begin groups\n") != std::string::npos); + REQUIRE(output.find(" 1 total 1,2\n") != std::string::npos); + REQUIRE(output.find(" 2 present 1\n") != std::string::npos); +} + +TEST_CASE("CVODE honors steady-state stopping", "[OdeOptions]") { + auto model = parseDecayModel(); + engine::NetworkGenerator generator(*model); + auto network = generator.generateNative(); + + engine::OdeOptions options; + options.method = "cvode"; + options.tEnd = 10.0; + options.nSteps = 10; + options.steadyState = true; + options.steadyStateTol = 100.0; + + const auto result = engine::OdeIntegrator(*model, network).integrate(options); + + REQUIRE(result.timePoints.size() < options.nSteps + 1); + REQUIRE(result.timePoints.back() < options.tEnd); +} + +TEST_CASE("SSA honors explicit sample times", "[OdeOptions]") { + auto model = parseDecayModel(); + engine::NetworkGenerator generator(*model); + auto network = generator.generateNative(); + + engine::OdeOptions options; + options.method = "ssa"; + options.tStart = 0.0; + options.tEnd = 10.0; + options.nSteps = 1; + options.seed = 42; + options.sampleTimes = {0.0, 0.25, 1.5, 10.0}; + + const auto result = engine::OdeIntegrator(*model, network).integrate(options); + + REQUIRE(result.timePoints == options.sampleTimes); + REQUIRE(result.concentrations.size() == options.sampleTimes.size()); + REQUIRE(result.observables.size() == options.sampleTimes.size()); +} + +TEST_CASE("OdeIntegrator preserves multi-species derivative updates", "[OdeOptions]") { + // Source-derived from akutuva21/bionetgen commit dd665873: compacting + // large constant-reaction updates must preserve the complete stoichiometric + // derivative, including multiple reactants and products. + ast::Model model; + engine::GeneratedNetwork network; + network.species.setCheckIso(false); + + for (double amount : {3.0, 4.0, 0.0, 0.0}) { + ast::SpeciesGraph graph; + network.species.add(ast::Species(graph, amount)); + } + + // Cross the source port's compact-reaction threshold while retaining a + // two-reactant, two-product update shape representative of generated + // networks. + for (std::size_t i = 0; i < 512; ++i) { + network.reactions.add(ast::Rxn( + "R" + std::to_string(i), {0, 1}, {2, 3}, "2.0", 1.0, + "rule" + std::to_string(i))); + } + + engine::OdeIntegrator integrator(model, network); + double state[] = {3.0, 4.0, 0.0, 0.0}; + double derivatives[] = {0.0, 0.0, 0.0, 0.0}; + integrator.derivs(0.0, state, derivatives); + + constexpr double expectedRate = 2.0 * 3.0 * 4.0 * 512.0; + REQUIRE_THAT(derivatives[0], Catch::Matchers::WithinAbs(-expectedRate, 1e-12)); + REQUIRE_THAT(derivatives[1], Catch::Matchers::WithinAbs(-expectedRate, 1e-12)); + REQUIRE_THAT(derivatives[2], Catch::Matchers::WithinAbs(expectedRate, 1e-12)); + REQUIRE_THAT(derivatives[3], Catch::Matchers::WithinAbs(expectedRate, 1e-12)); +} diff --git a/tests/cpp/test_reaction_rule_expansion.cpp b/tests/cpp/test_reaction_rule_expansion.cpp index 8b59d2bc..07d38d42 100644 --- a/tests/cpp/test_reaction_rule_expansion.cpp +++ b/tests/cpp/test_reaction_rule_expansion.cpp @@ -1,4 +1,5 @@ #include +#include #include "parser/BNGAstVisitor.hpp" #include "engine/NetworkGenerator.hpp" @@ -9,6 +10,38 @@ static std::unique_ptr parseModel(const std::string& bngl) { return parser::parseModel(bngl); } +TEST_CASE("Rule expansion: pattern metadata survives reinitialization and move", "[ReactionRule]") { + // Source-derived from akutuva21/bionetgen commit 7ee2db11: immutable + // pattern metadata is rebuilt on initialize() and must survive moving a + // fully initialized rule into the owning model before expansion. + auto model = parseModel(R"( +begin parameters + kf 1.0 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + A() 100 +end seed species +begin reaction rules + A() -> B() kf +end reaction rules +)"); + + auto rule = std::move(model->getReactionRules().front()); + model->getReactionRules().clear(); + rule.initialize(); + model->addReactionRule(std::move(rule)); + + engine::NetworkGenerator generator(*model); + const auto network = generator.generateNative(5); + + REQUIRE(network.species.size() == 2); + REQUIRE(network.reactions.size() == 1); +} + TEST_CASE("Rule expansion: simple binding A + B -> AB", "[ReactionRule]") { auto model = parseModel(R"( begin parameters @@ -67,6 +100,46 @@ end actions REQUIRE(network.reactions.size() >= 1); } +TEST_CASE("Rule expansion: deleting a bound molecule preserves a free site", "[ReactionRule]") { + // BNG2 serializes an unbound component without an edge marker. A + // DeleteMolecules product must therefore remain the same species as the + // independently seeded BNG2-equivalent graph, even when the deleted bond + // was the component's only explicit internal unbound marker. + auto model = parseModel(R"( +begin parameters + k 1.0 +end parameters +begin molecule types + DNA(p1,p2) + P1(dna) + TF(d,dna) + Sink() +end molecule types +begin seed species + DNA(p1!1!2,p2).TF(d!3,dna!2).TF(d!3,dna!1) 1 + DNA(p1!1!2,p2!4).P1(dna!4).TF(d!3,dna!1).TF(d!3,dna!2) 1 +end seed species +begin reaction rules + P1 -> Sink() k DeleteMolecules +end reaction rules +)"); + + engine::NetworkGenerator generator(*model); + const auto network = generator.generateNative(4); + + std::size_t targetCount = 0; + for (std::size_t index = 0; index < network.species.size(); ++index) { + const auto text = network.species.get(index).getSpeciesGraph().toString(); + REQUIRE_FALSE(text.empty()); + if (text.find("DNA(p1!1!2,p2)") == 0 && text.find("TF(") != std::string::npos && + text.find("P1(") == std::string::npos) { + ++targetCount; + } + } + REQUIRE(targetCount == 1); + REQUIRE(network.reactions.size() == 1); +} + TEST_CASE("Rule expansion: bidirectional rule decomposition", "[ReactionRule]") { auto model = parseModel(R"( begin parameters diff --git a/tests/cpp/test_release_lto.cmake b/tests/cpp/test_release_lto.cmake new file mode 100644 index 00000000..be4c3c34 --- /dev/null +++ b/tests/cpp/test_release_lto.cmake @@ -0,0 +1,47 @@ +# Source-derived from NFsim commit 301bfbeb: Release LTO must be guarded by +# CheckIPOSupported and applied to the shared NFsim implementation plus each +# built consumer, while an explicit opt-out must disable the properties. + +if(NOT DEFINED CONTRACT_PATH OR NOT EXISTS "${CONTRACT_PATH}") + message(FATAL_ERROR "BNG3 LTO contract report is missing") +endif() + +file(READ "${CONTRACT_PATH}" contract) +string(REPLACE "\n" ";" contract_lines "${contract}") + +function(_contract_value key output) + foreach(line IN LISTS contract_lines) + if(line MATCHES "^${key}=(.*)$") + set(${output} "${CMAKE_MATCH_1}" PARENT_SCOPE) + return() + endif() + endforeach() + message(FATAL_ERROR "BNG3 LTO contract report lacks ${key}") +endfunction() + +_contract_value(NFSIM_ENABLE_LTO enable) +_contract_value(NFSIM_IPO_SUPPORTED supported) + +set(targets + nfsim_core + bng_cpp + _bionetgen_cpp + NFsim) +foreach(target IN LISTS targets) + _contract_value(${target}_RELEASE_IPO _${target}_ipo) + set(${target}_RELEASE_IPO "${_${target}_ipo}") +endforeach() + +set(expected FALSE) +if(enable STREQUAL "ON" AND supported STREQUAL "TRUE") + set(expected TRUE) +endif() + +foreach(target IN LISTS targets) + set(actual "${${target}_RELEASE_IPO}") + if(NOT actual STREQUAL "NOT_BUILT" AND NOT actual STREQUAL "${expected}") + message(FATAL_ERROR + "${target} Release IPO is ${actual}; expected ${expected}" + ) + endif() +endforeach() diff --git a/tests/cpp/test_sbml_reader.cpp b/tests/cpp/test_sbml_reader.cpp new file mode 100644 index 00000000..040e89c3 --- /dev/null +++ b/tests/cpp/test_sbml_reader.cpp @@ -0,0 +1,62 @@ +#include + +#include +#include +#include + +#include "io/SbmlReader.hpp" + +TEST_CASE("SBML reader imports a flat reaction network", "[SbmlReader]") { + const auto path = std::filesystem::temp_directory_path() / "bng3_sbml_reader_test.xml"; + std::ofstream out(path); + out << R"xml( + + + + + + + + + k + + + + + + + kS1 + + + + +)xml"; + out.close(); + + const auto parsed = bng::io::SbmlReader::parse(path); + std::filesystem::remove(path); + + REQUIRE(parsed.success); + REQUIRE(parsed.parameters.at("k") == 3.0); + REQUIRE(parsed.functions.size() == 1); + CHECK(parsed.functions.front().first == "dim"); + CHECK(parsed.functions.front().second == "k"); + REQUIRE(parsed.species.size() == 1); + CHECK(parsed.species.front().first == "@cell::A____()"); + CHECK(parsed.species.front().second == "2"); + REQUIRE(parsed.reactions.size() == 1); + CHECK(parsed.reactions.front().find("1 1 0 k") != std::string::npos); +} + +TEST_CASE("SBML reader rejects atomized conversion requests", "[SbmlReader]") { + const auto path = std::filesystem::temp_directory_path() / "bng3_sbml_reader_atomize.xml"; + std::ofstream out(path); + out << "\n"; + out.close(); + + const auto parsed = bng::io::SbmlReader::parse(path, true); + std::filesystem::remove(path); + + REQUIRE_FALSE(parsed.success); + CHECK(parsed.error.find("atomize") != std::string::npos); +} diff --git a/tests/fixtures/issue_328_missing_net.bngl b/tests/fixtures/issue_328_missing_net.bngl new file mode 100644 index 00000000..56dabb3d --- /dev/null +++ b/tests/fixtures/issue_328_missing_net.bngl @@ -0,0 +1 @@ +readFile({file=>"missing-issue-328.net"}) diff --git a/tests/python/test_bngfile_compat.py b/tests/python/test_bngfile_compat.py new file mode 100644 index 00000000..4717c6ce --- /dev/null +++ b/tests/python/test_bngfile_compat.py @@ -0,0 +1,95 @@ +"""Compatibility contracts for the legacy ``BNGFile`` facade.""" + +from __future__ import annotations + +import io +import xml.etree.ElementTree as ET + +from bionetgen.modelapi.bngfile import BNGFile + + +def test_write_xml_accepts_the_file_owned_bngl_source(tmp_path): + source = tmp_path / "file_owned.bngl" + source.write_text(""" +begin model +begin parameters + k 1 +end parameters +begin molecule types + A() +end molecule types +begin seed species + A() 2 +end seed species +begin reaction rules + A() -> 0 k +end reaction rules +end model +""") + + output = io.StringIO() + assert BNGFile(str(source)).write_xml(output) + + root = ET.fromstring(output.getvalue()) + assert root.tag.endswith("sbml") + assert root.find(".//{*}ListOfReactionRules") is not None + + +def test_write_xml_file_owned_source_supports_sbml(tmp_path): + source = tmp_path / "file_owned_sbml.bngl" + source.write_text(""" +begin model +begin parameters + k 1 +end parameters +begin molecule types + A() +end molecule types +begin seed species + A() 2 +end seed species +begin observables + Molecules Atot A() +end observables +begin reaction rules + A() -> 0 k +end reaction rules +end model +""") + + output = io.StringIO() + assert BNGFile(str(source)).write_xml(output, xml_type="sbml") + + root = ET.fromstring(output.getvalue()) + assert root.tag.endswith("sbml") + assert root.find(".//{*}listOfReactions") is not None + + +def test_legacy_bngmodel_loads_blocks_without_perl_bng(tmp_path): + import bionetgen + + source = tmp_path / "legacy_modelapi.bngl" + source.write_text(""" +begin model +begin parameters + k 1 +end parameters +begin molecule types + A() +end molecule types +begin seed species + A() 2 +end seed species +begin reaction rules + A() -> 0 k +end reaction rules +end model +""") + + model = bionetgen.bngmodel(str(source)) + + assert model.model_name == "legacy_modelapi" + assert len(model.parameters) == 1 + assert len(model.molecule_types) == 1 + assert len(model.species) == 1 + assert len(model.rules) == 1 diff --git a/tests/python/test_builder.py b/tests/python/test_builder.py index 9102664e..11bc4c84 100644 --- a/tests/python/test_builder.py +++ b/tests/python/test_builder.py @@ -26,3 +26,9 @@ def test_builder_to_bngl_and_simulation(): model = builder.build() result = model.simulate(method="ode", t_end=50, n_steps=100) assert result.observables["AB"][-1] > result.observables["AB"][0] + + +def test_builder_preserves_model_name(): + model = ModelBuilder("NamedModel").build() + + assert model.name == "NamedModel" diff --git a/tests/python/test_cli.py b/tests/python/test_cli.py index 218cf1f3..31b33ce1 100644 --- a/tests/python/test_cli.py +++ b/tests/python/test_cli.py @@ -3,6 +3,7 @@ import os import pytest from click.testing import CliRunner +from unittest.mock import patch try: from bionetgen.cli import main @@ -41,6 +42,33 @@ def test_cli_check(runner, simple_model): assert result.exit_code == 0 +def test_cli_legacy_run_flags_preserve_action_outputs(runner, tmp_path): + output = tmp_path / "legacy-results" + result = runner.invoke( + main, + [ + "run", + "-i", + os.path.join(os.path.dirname(__file__), "test.bngl"), + "-o", + str(output), + ], + ) + + assert result.exit_code == 0, result.output + assert {"test.net", "test.xml", "test.gdat", "test.cdat"}.issubset( + {path.name for path in output.iterdir()} + ) + + +@pytest.mark.parametrize( + "command", ["info", "plot", "notebook", "graphdiff", "atomize"] +) +def test_cli_legacy_command_surface(runner, command): + result = runner.invoke(main, [command, "--help"]) + assert result.exit_code == 0, result.output + + def test_cli_export_bngl(runner, simple_model, tmp_path): out = str(tmp_path / "output.bngl") result = runner.invoke( @@ -50,6 +78,36 @@ def test_cli_export_bngl(runner, simple_model, tmp_path): assert os.path.exists(out) +def test_cli_visualize_legacy_flags_write_graphml(runner, simple_model, tmp_path): + result = runner.invoke( + main, + [ + "visualize", + "-i", + simple_model, + "--type", + "contactmap", + "-o", + str(tmp_path), + ], + ) + + assert result.exit_code == 0, result.output + assert any(path.suffix == ".graphml" for path in tmp_path.iterdir()) + + +def test_legacy_bngcli_does_not_fallback_after_cpp_failure(tmp_path): + from bionetgen.core.tools.cli import BNGCLI + + model = tmp_path / "invalid.bngl" + model.write_text("begin model\nend model\n") + cli = BNGCLI(model, tmp_path / "output", str(tmp_path / "missing"), suppress=True) + with patch("bionetgen._bionetgen_cpp.parse_file") as parse_file: + parse_file.side_effect = RuntimeError("backend parse failure") + with pytest.raises(RuntimeError, match="backend parse failure"): + cli.run() + + @pytest.mark.parametrize("method", ["pla", "psa"]) def test_cli_approximate_simulation_start_time(runner, simple_model, method): result = runner.invoke( @@ -70,13 +128,25 @@ def test_cli_approximate_simulation_start_time(runner, simple_model, method): assert result.exit_code == 0, result.output -def test_cli_nf_rejects_nonzero_start_time(runner, simple_model): +def test_cli_nf_honors_nonzero_start_time(runner, simple_model): result = runner.invoke( main, - ["run", simple_model, "--method", "nf", "--t-start", "1"], + [ + "run", + simple_model, + "--method", + "nf", + "--t-start", + "1", + "--t-end", + "2", + "--n-steps", + "1", + "--seed", + "1", + ], ) - assert result.exit_code != 0 - assert "t-start" in result.output + assert result.exit_code == 0, result.output def test_cli_scan_and_sensitivity_forward_simulation_options(runner, tmp_path): diff --git a/tests/python/test_compatibility_contract.py b/tests/python/test_compatibility_contract.py new file mode 100644 index 00000000..30e07da1 --- /dev/null +++ b/tests/python/test_compatibility_contract.py @@ -0,0 +1,64 @@ +"""Small, source-derived PyBioNetGen compatibility contracts.""" + +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys + +import numpy as np +import pytest + +import bionetgen + +REPO = Path(__file__).resolve().parents[2] +MODEL = Path(__file__).with_name("test.bngl") + + +def test_legacy_public_symbols_are_available_from_package_root(): + assert callable(bionetgen.bngmodel) + assert callable(bionetgen.run) + assert callable(bionetgen.sim_getter) + assert bionetgen.SympyOdes is not None + assert callable(bionetgen.export_sympy_odes) + assert bionetgen.BNGResult is not None + + +def test_legacy_run_accepts_output_directory_and_returns_file_result(tmp_path): + output = tmp_path / "results" + + result = bionetgen.run(MODEL, output) + + assert isinstance(result, bionetgen.BNGResult) + assert result.process_return == 0 + assert {"test.net", "test.xml", "test.gdat", "test.cdat"}.issubset( + {path.name for path in output.iterdir()} + ) + assert result.gdats["test"].dtype.names[0] == "time" + assert np.isfinite(result.gdats["test"]["time"]).all() + + +def test_module_entry_point_exposes_cli_help(): + env = os.environ.copy() + source_python = str(REPO / "python") + env["PYTHONPATH"] = os.pathsep.join( + [source_python, str(REPO / "build" / "cpp"), env.get("PYTHONPATH", "")] + ) + result = subprocess.run( + [sys.executable, "-m", "bionetgen", "--help"], + cwd=REPO, + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "Usage:" in result.stdout + assert "BioNetGen" in result.stdout + + +def test_legacy_sim_getter_rejects_unknown_simulator_type(): + with pytest.raises(ValueError, match="not supported"): + bionetgen.sim_getter(model_file=str(MODEL), sim_type="unknown") diff --git a/tests/python/test_cpp_backend.py b/tests/python/test_cpp_backend.py index c212661b..1ceb1700 100644 --- a/tests/python/test_cpp_backend.py +++ b/tests/python/test_cpp_backend.py @@ -2,6 +2,7 @@ import os import xml.etree.ElementTree as ET +from pathlib import Path import pytest @@ -101,6 +102,97 @@ def test_file_not_found(self): with pytest.raises(_cpp.ParseError): _cpp.parse_file("/nonexistent/path.bngl") + def test_actions_after_model_blocks_are_accepted(self, tmp_path): + bngl = tmp_path / "actions_inside_model.bngl" + bngl.write_text(""" +begin model +begin parameters + k_deg 1.0 + k_syn 10.0 +end parameters +begin molecule types + A() + B() +end molecule types +begin seed species + $A() 5 + B() 0 +end seed species +begin observables + Molecules Atot A() + Molecules Btot B() +end observables +begin reaction rules + A() -> 0 k_deg + 0 -> B() k_syn +end reaction rules +generate_network({overwrite=>1}) +simulate_nf({prefix=>"inside_model",t_end=>1,n_steps=>1,seed=>7}) +end model +""") + + bionetgen.load(str(bngl)).execute() + + output = tmp_path / "inside_model.gdat" + assert output.exists() + assert "Atot" in output.read_text() + + def test_integer_state_increment_and_decrement_rules_are_expanded(self, tmp_path): + bngl = tmp_path / "integer_states.bngl" + bngl.write_text(""" +begin parameters + kr 5 + kb 20 +end parameters +begin seed species + ReceptorDimer(m~3) 4000 +end seed species +begin observables + Molecules R0 ReceptorDimer(m~0) + Molecules R8 ReceptorDimer(m~8) +end observables +begin reaction rules + ReceptorDimer(m~^[8]) -> ReceptorDimer(m~++) kr + ReceptorDimer(m~^[0]) -> ReceptorDimer(m~--) kb +end reaction rules +""") + + model = _cpp.parse_file(str(bngl)) + + # Eight transitions in each direction, with the integer state range + # inferred from the two native boundary rules. + assert len(model.reaction_rules) == 16 + + def test_singular_function_block_header_is_accepted(self, tmp_path): + """BNG2/NFsim-era fixtures use ``begin function`` as a legacy alias.""" + bngl = tmp_path / "singular_function_block.bngl" + bngl.write_text(""" +begin parameters + k 2 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +begin function + rate() = k +end function +begin reaction rules + X() -> 0 rate() +end reaction rules +""") + + model = _cpp.parse_file(str(bngl)) + + assert len(model.functions) == 1 + assert model.functions[0].name == "rate" + assert len(model.reaction_rules) == 1 + class TestNetworkGeneration: def test_generate_simple(self, tmp_path): @@ -149,8 +241,84 @@ def test_validation_models_generate_network(self, model_name, expected_species): # the strict exception ledger covers the known blbr over-count. assert network.num_reactions > 0 + def test_cbnGL_iteration_three_deduplicates_compartmented_isomorphs(self): + """Match the BNG2 iteration-3 boundary for the cBNGL fixture. + + The independent BNG2 oracle reports 16 species after iteration 3. + Two isomorphic compartmented products can serialize their root + molecules in different orders; that representation detail must not + create a second species. + """ + model = _cpp.parse_file(get_model_path("Motivating_example_cBNGL.bngl")) + network = _cpp.generate_network(model, max_iter=3) + + assert network.num_species == 16 + + def test_model_function_forward_rate_keeps_complex_reverse_rate(self, tmp_path): + """A model-function forward rate must not drop its reverse rate.""" + bngl = tmp_path / "reversible_function_rate.bngl" + bngl.write_text( + """ +begin model +begin parameters + k 3 +end parameters +begin molecule types + A() +end molecule types +begin seed species + A() 0 +end seed species +begin functions + forward() = 1 +end functions +begin reaction rules + 0 <-> A() forward(),10^k +end reaction rules +end model +""", + encoding="utf-8", + ) + + model = bionetgen.load(str(bngl)) + model.generate_network() + net = tmp_path / "reversible_function_rate.net" + model.write_net(str(net)) + + text = net.read_text(encoding="utf-8") + assert "rateLaw1 1000" in text + assert "rateLaw1 #_reverse__R1" in text + assert "(10 ^ k) #_reverse__R1" not in text + class TestSimulation: + def test_bng2_zero_argument_function_rates_execute(self, tmp_path): + """The BNG2 test_time fixture must execute through the action path.""" + source = Path(VALIDATION_DIR) / "Validate" / "test_time.bngl" + bngl = tmp_path / source.name + bngl.write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + + bionetgen.load(str(bngl)).execute() + + output = tmp_path / "test_time.gdat" + assert output.exists() + rows = output.read_text(encoding="utf-8").splitlines() + assert rows[0].lstrip("#").split() == [ + "time", + "t", + "x_exact", + "x_builtin", + "f_correct", + "f_builtin", + "f_diff", + ] + assert len(rows) == 102 # header plus 100 output intervals and t=0 + final = [float(value) for value in rows[-1].split()] + assert final[1] == pytest.approx(10.0) + assert final[4] == pytest.approx(10.0) + assert final[5] == pytest.approx(10.0) + assert final[6] == pytest.approx(0.0, abs=1e-10) + def test_ode_simulation(self, tmp_path): bngl = tmp_path / "sim.bngl" bngl.write_text(""" @@ -207,6 +375,49 @@ def test_ssa_simulation(self, tmp_path): result = _cpp.simulate_ssa(model, network, t_end=10.0, n_steps=50, seed=42) assert "time" in result + def test_simulation_output_controls(self, tmp_path): + bngl = tmp_path / "controls.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 100 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +end model +""") + model = _cpp.parse_file(str(bngl)) + network = _cpp.generate_network(model) + sample_times = [0.0, 0.25, 1.5, 10.0] + + ode = _cpp.simulate_ode( + model, network, t_end=10.0, n_steps=0, sample_times=sample_times + ) + assert ode["time"].tolist() == sample_times + + ssa = _cpp.simulate_ssa( + model, + network, + t_end=10.0, + n_steps=0, + sample_times=sample_times, + seed=42, + ) + assert ssa["time"].tolist() == sample_times + + with pytest.raises(RuntimeError, match="stop_if"): + _cpp.simulate_ode(model, network, t_end=1.0, n_steps=1, stop_if="Xtot <") + @pytest.mark.parametrize("method", ["pla", "psa"]) def test_approximate_simulation_respects_start_time(self, tmp_path, method): bngl = tmp_path / f"{method}.bngl" @@ -269,74 +480,1183 @@ def test_nf_simulation(self, tmp_path): assert "time" in result assert "observables" in result + assert result["construction_path"] == "direct" assert len(result["time"]) == 11 assert result["time"][0] == 0.0 assert result["time"][-1] == pytest.approx(5.0) obs_values = next(iter(result["observables"].values())) assert len(obs_values) == 11 - -class TestHighLevelAPI: - def test_load_and_simulate(self, tmp_path): - bngl = tmp_path / "api.bngl" + def test_nf_simulation_honors_absolute_start_time(self, tmp_path): + # Source-derived from NFsim test/Issue78/issue78.bngl and afad408: + # absolute start time must affect both the output axis and a generic + # time() function used as a reaction rate. + bngl = tmp_path / "issue78.bngl" bngl.write_text(""" begin model -begin parameters - k 0.1 -end parameters begin molecule types X() end molecule types begin seed species - X() 100 + X() 0 end seed species begin observables Molecules Xtot X() end observables +begin functions + stimulus() = time() +end functions begin reaction rules - X() -> 0 k + R: 0 -> X() stimulus() end reaction rules end model """) model = bionetgen.load(str(bngl)) - assert len(model.parameters) == 1 - assert len(model.reaction_rules) == 1 + result = model.simulate( + method="nf", + t_start=100.0, + t_end=102.0, + n_steps=0, + sample_times=[100.0, 101.0, 102.0], + seed=1, + ) - result = model.simulate(method="ode", t_end=10.0, n_steps=50) - assert result.n_steps == 51 - assert len(result.observable_names) >= 1 + assert result.time.tolist() == pytest.approx([100.0, 101.0, 102.0]) + assert result.observables["Xtot"][-1] > 100.0 - for method in ["pla", "psa"]: - result = model.simulate(method=method, t_start=2.0, t_end=4.0, n_steps=2) - assert result.time[0] == pytest.approx(2.0) - assert result.time[-1] == pytest.approx(4.0) + def test_nf_sampling_matches_nfsim_accumulated_output_grid(self, tmp_path): + # Source-derived from NFsim System::sim: output checkpoints advance by + # repeated dSampleTime addition, not by multiplying the step index. + bngl = tmp_path / "nf_sample_grid.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +end model +""") + model = _cpp.parse_file(str(bngl)) + result = _cpp.simulate_nf(model, t_end=0.2, n_steps=20, seed=1) - with pytest.raises(ValueError, match="t_start=0.0"): - model.simulate(method="nf", t_start=1.0, t_end=2.0, n_steps=1) + dt = 0.2 / 20 + expected = [0.0] + for _ in range(20): + expected.append(expected[-1] + dt) + assert result["time"].tolist() == expected - def test_set_parameter(self, tmp_path): - bngl = tmp_path / "param.bngl" + def test_nf_simulation_accepts_traversal_limit(self, tmp_path): + bngl = tmp_path / "nf_traversal_limit.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +end model +""") + + model = _cpp.parse_file(str(bngl)) + result = _cpp.simulate_nf( + model, t_end=1.0, n_steps=1, seed=1, traversal_limit=1 + ) + + assert result["construction_path"] == "direct" + assert result["time"].tolist() == [0.0, 1.0] + + def test_nf_simulation_resolves_relative_tfun_from_model_path( + self, tmp_path, monkeypatch + ): + table_dir = tmp_path / "tables" + table_dir.mkdir() + (table_dir / "rate.dat").write_text("0 1\n10 1\n") + bngl = tmp_path / "relative_tfun.bngl" + bngl.write_text(""" +begin model +begin parameters +end parameters +begin molecule types + A() +end molecule types +begin seed species + A() 1 +end seed species +begin observables + Molecules A_total A() +end observables +begin functions + rate = TFUN(time, "tables/rate.dat") +end functions +begin reaction rules + A() -> 0 rate +end reaction rules +end model +""") + + monkeypatch.chdir(tmp_path.parent) + model = bionetgen.load(str(bngl)) + result = model.simulate(method="nf", t_end=1.0, n_steps=2, seed=1) + + assert result.time[-1] == pytest.approx(1.0) + assert result.observables["A_total"][0] == pytest.approx(1.0) + + def test_nf_direct_local_function_accepts_bare_global_observable( + self, tmp_path, monkeypatch + ): + bngl = tmp_path / "local_global_observable.bngl" bngl.write_text(""" begin model begin parameters k 0.1 end parameters begin molecule types - X() + A(s~0~1) + C(d) end molecule types begin seed species - X() 100 + A(s~0) 1 + C(d!1).C(d!1) 1 end seed species begin observables - Molecules Xtot X() + Molecules Atot A() + Molecules Ctot C() end observables +begin functions + loc(x) = k*Atot + 0*Ctot(x) +end functions begin reaction rules - X() -> 0 k + A(s~0) + C(d!1)%x.C(d!1) -> A(s~1) + C(d!1)%x.C(d!1) loc(x) end reaction rules end model """) + + monkeypatch.setenv("BNG_NFSIM_REQUIRE_DIRECT", "1") model = bionetgen.load(str(bngl)) - model.set_parameter("k", 0.5) + direct = model.simulate(method="nf", t_end=1.0, n_steps=2, seed=1) + + monkeypatch.delenv("BNG_NFSIM_REQUIRE_DIRECT") + monkeypatch.setenv("BNG_NFSIM_FORCE_XML", "1") + monkeypatch.setenv("BNG_NFSIM_ALLOW_XML_FALLBACK", "1") + xml = model.simulate(method="nf", t_end=1.0, n_steps=2, seed=1) + + assert direct.time.tolist() == xml.time.tolist() + assert direct.observable_names == xml.observable_names + for name in direct.observable_names: + assert direct.observables[name].tolist() == xml.observables[name].tolist() + + def test_nf_direct_dynamic_rate_wraps_scoped_local_function( + self, tmp_path, monkeypatch + ): + bngl = tmp_path / "dynamic_scoped_local_rate.bngl" + bngl.write_text(""" +begin model +begin parameters +end parameters +begin molecule types + A(s~0~1) +end molecule types +begin seed species + A(s~0) 1 +end seed species +begin observables + Molecules A1 A(s~1) +end observables +begin functions + tally(x) = A1(x) +end functions +begin reaction rules + A(s~0)%x -> A(s~1) 2 + tally(x) +end reaction rules +end model +""") + + monkeypatch.setenv("BNG_NFSIM_REQUIRE_DIRECT", "1") + result = bionetgen.load(str(bngl)).simulate( + method="nf", t_end=0.0, n_steps=1, seed=1 + ) + + assert result.time.tolist() == [0.0, 0.0] + + def test_nf_direct_route_can_be_required(self, tmp_path, monkeypatch): + bngl = tmp_path / "strict_nf.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +end model +""") + model = _cpp.parse_file(str(bngl)) + monkeypatch.setenv("BNG_NFSIM_FORCE_XML", "1") + monkeypatch.setenv("BNG_NFSIM_REQUIRE_DIRECT", "1") + + with pytest.raises(RuntimeError, match="direct AST initialization required"): + _cpp.simulate_nf(model, t_end=1.0, n_steps=1) + + def test_nf_xml_fallback_requires_explicit_opt_in(self, tmp_path, monkeypatch): + bngl = tmp_path / "implicit_xml_nf.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +end model +""") + model = _cpp.parse_file(str(bngl)) + monkeypatch.setenv("BNG_NFSIM_FORCE_XML", "1") + monkeypatch.delenv("BNG_NFSIM_REQUIRE_DIRECT", raising=False) + monkeypatch.delenv("BNG_NFSIM_ALLOW_XML_FALLBACK", raising=False) + + with pytest.raises(RuntimeError, match="XML fallback disabled"): + _cpp.simulate_nf(model, t_end=1.0, n_steps=1) + + def test_simulate_method_nf_uses_nf_route(self, tmp_path, monkeypatch): + bngl = tmp_path / "action_nf.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +begin actions + simulate({method=>"nf",suffix=>"route",t_end=>1,n_steps=>2,seed=>1}) +end actions +end model +""") + monkeypatch.setenv("BNG_NFSIM_REQUIRE_DIRECT", "1") + + model = bionetgen.load(str(bngl)) + model.execute() + + assert (tmp_path / "action_nf_route.gdat").exists() + assert (tmp_path / "action_nf_route.species").exists() + + def test_action_nf_xml_fallback_requires_explicit_opt_in( + self, tmp_path, monkeypatch + ): + bngl = tmp_path / "implicit_action_xml_nf.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin actions + simulate_nf({prefix=>"implicit",t_end=>1,n_steps=>1}) +end actions +end model +""") + monkeypatch.setenv("BNG_NFSIM_FORCE_XML", "1") + monkeypatch.delenv("BNG_NFSIM_REQUIRE_DIRECT", raising=False) + monkeypatch.delenv("BNG_NFSIM_ALLOW_XML_FALLBACK", raising=False) + + with pytest.raises(RuntimeError, match="XML fallback disabled"): + bionetgen.load(str(bngl)).execute() + + def test_simulate_nf_rejects_unsupported_continue(self, tmp_path): + bngl = tmp_path / "nf_continue.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin actions + simulate_nf({continue=>1,t_end=>1,n_steps=>1}) +end actions +end model +""") + + with pytest.raises(RuntimeError, match="NFsim does not support 'continue'"): + bionetgen.load(str(bngl)).execute() + + @pytest.mark.parametrize( + ("option", "value", "message"), + [ + ("param", '"-unsupported_nf_flag"', "param"), + ], + ) + def test_simulate_nf_rejects_unhandled_controls( + self, tmp_path, option, value, message + ): + bngl = tmp_path / f"nf_{option}.bngl" + bngl.write_text(f""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin actions + simulate_nf({{{option}=>{value},t_end=>1,n_steps=>1}}) +end actions +end model +""") + + with pytest.raises(RuntimeError, match=message): + bionetgen.load(str(bngl)).execute() + + def test_simulate_nf_accepts_legacy_supported_param_flags(self, tmp_path): + bngl = tmp_path / "nf_param_compat.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +begin actions + simulate_nf({prefix=>"param_compat",t_end=>1,n_steps=>1,param=>"-gml 1000000 -utl 4 -notf"}) +end actions +end model +""") + + bionetgen.load(str(bngl)).execute() + + output = tmp_path / "param_compat.gdat" + assert output.exists() + assert "X_total" in output.read_text() + + def test_simulate_nf_action_preserves_explicit_traversal_limit( + self, tmp_path, capfd + ): + bngl = tmp_path / "nf_utl.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +begin actions + simulate_nf({prefix=>"utl",t_end=>0,n_steps=>1,utl=>1,verbose=>1}) +end actions +end model +""") + + bionetgen.load(str(bngl)).execute(verbose=True) + + assert "Universal traversal limit = 1" in capfd.readouterr().err + + def test_simulate_nf_separates_complex_bookkeeping_from_ring_blocking( + self, tmp_path + ): + """NFsim's -cb and -bscb switches must keep their distinct meanings. + + Native NFsim enables complex bookkeeping for ``-cb`` but blocks an + intracomplex bond only when ``-bscb`` is also present. This fixture + makes that distinction observable with a high-rate ring closure. + """ + bngl = tmp_path / "nf_complex_flags.bngl" + bngl.write_text(""" +begin model +begin parameters + k 1000000 +end parameters +begin molecule types + A(x,y) + B(x,z) +end molecule types +begin seed species + A(x!1,y).B(x!1,z) 1 +end seed species +begin observables + Molecules closed A(y!2).B(z!2) +end observables +begin reaction rules + A(x!1,y) + B(x!1,z) -> A(x!1,y!2).B(x!1,z!2) k +end reaction rules +begin actions + simulate_nf({prefix=>"cb",t_end=>1,n_steps=>1,seed=>1,complex=>1}) + simulate_nf({prefix=>"bscb",t_end=>1,n_steps=>1,seed=>1,complex=>1,param=>"-bscb"}) +end actions +end model +""") + + bionetgen.load(str(bngl)).execute() + + cb_rows = (tmp_path / "cb.gdat").read_text().splitlines() + bscb_rows = (tmp_path / "bscb.gdat").read_text().splitlines() + assert float(cb_rows[-1].split()[1]) == pytest.approx(1.0) + assert float(bscb_rows[-1].split()[1]) == pytest.approx(0.0) + + def test_simulate_nf_action_honors_sample_times(self, tmp_path): + bngl = tmp_path / "nf_sample_times.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +begin actions + simulate_nf({prefix=>"sample_times",t_end=>1,sample_times=>[0,0.25,0.75]}) +end actions +end model +""") + + bionetgen.load(str(bngl)).execute() + + output = tmp_path / "sample_times.gdat" + rows = [ + line.split() + for line in output.read_text().splitlines() + if line and not line.startswith("#") + ] + assert [float(row[0]) for row in rows] == pytest.approx([0.0, 0.25, 0.75, 1.0]) + + def test_simulate_nf_action_honors_absolute_start_time(self, tmp_path): + # Source-derived from NFsim test/Issue78/issue78.bngl and afad408: + # action execution must preserve the absolute time axis and evaluate + # time() at that axis. + bngl = tmp_path / "issue78_action.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 0 +end seed species +begin observables + Molecules Xtot X() +end observables +begin functions + stimulus() = time() +end functions +begin reaction rules + R: 0 -> X() stimulus() +end reaction rules +begin actions + simulate_nf({prefix=>"issue78",t_start=>100,t_end=>102,sample_times=>[100,101,102],seed=>1,print_functions=>1}) +end actions +end model +""") + + bionetgen.load(str(bngl)).execute() + + output = tmp_path / "issue78.gdat" + rows = [ + line.split() + for line in output.read_text().splitlines() + if line and not line.startswith("#") + ] + assert [float(row[0]) for row in rows] == pytest.approx([100.0, 101.0, 102.0]) + assert "stimulus" in output.read_text() + + def test_simulate_nf_action_outputs_global_functions(self, tmp_path): + bngl = tmp_path / "nf_functions.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +begin functions + rate = k +end functions +begin reaction rules + X() -> 0 rate +end reaction rules +begin actions + simulate_nf({prefix=>"functions",t_end=>1,n_steps=>1,print_functions=>1}) +end actions +end model +""") + + bionetgen.load(str(bngl)).execute() + + output = tmp_path / "functions.gdat" + assert "rate" in output.read_text() + + def test_simulate_nf_action_binary_output_writes_header(self, tmp_path): + bngl = tmp_path / "nf_binary.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin actions + simulate_nf({prefix=>"binary",t_end=>1,n_steps=>1,binary_output=>1}) +end actions +end model +""") + + bionetgen.load(str(bngl)).execute() + + output = tmp_path / "binary.gdat" + assert output.exists() + assert output.stat().st_size > 0 + assert (tmp_path / "binary.gdat.head").exists() + assert "Time" in (tmp_path / "binary.gdat.head").read_text() + + def test_simulate_nf_action_rejects_invalid_equilibration(self, tmp_path): + bngl = tmp_path / "nf_bad_equil.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin actions + simulate_nf({equil=>-1,t_end=>1,n_steps=>1}) +end actions +end model +""") + + with pytest.raises(RuntimeError, match="equil.*non-negative"): + bionetgen.load(str(bngl)).execute() + + def test_action_rejects_unknown_simulation_method(self, tmp_path): + bngl = tmp_path / "unknown_method.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin observables + Molecules X_total X() +end observables +begin reaction rules + X() -> 0 1 +end reaction rules +begin actions + simulate({method=>"not_a_method",t_end=>1,n_steps=>1}) +end actions +end model +""") + + with pytest.raises(RuntimeError, match="unsupported simulation method"): + bionetgen.load(str(bngl)).execute() + + def test_action_rejects_unknown_visualization_type(self, tmp_path): + bngl = tmp_path / "unknown_visualization.bngl" + bngl.write_text(""" +begin model +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin actions + visualize({type=>"not_a_visualization"}) +end actions +end model +""") + + with pytest.raises(RuntimeError, match="unsupported visualization type"): + bionetgen.load(str(bngl)).execute() + + def test_writefile_honors_ssc_prefix_suffix_and_overwrite(self, tmp_path): + bngl = tmp_path / "writefile_contract.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 1 +end seed species +begin reaction rules + X() -> 0 k +end reaction rules +end model + +writeFile({format=>"ssc",prefix=>"export",suffix=>"v1"}) +""") + + model = bionetgen.load(str(bngl)) + model.execute() + + output = tmp_path / "export_v1.rxn" + assert output.exists() + assert "new X" in output.read_text() + + with pytest.raises(RuntimeError, match="file exists"): + model.execute() + + def test_readnetwork_alias_loads_net_data(self, tmp_path): + net_path = tmp_path / "import.net" + net_path.write_text("""# imported network +begin parameters + 1 k 0.2 +end parameters +begin species + 1 X() 7 +end species +begin reactions + 1 1 0 k +end reactions +""") + bngl = tmp_path / "readnetwork.bngl" + bngl.write_text(""" +begin model +end model + +readNetwork({file=>"import.net"}) +writeNetwork() +""") + + bionetgen.load(str(bngl)).execute() + + output = tmp_path / "readnetwork.net" + assert output.exists() + assert "X() 7" in output.read_text() + + def test_readnetwork_alias_rejects_missing_file(self, tmp_path): + bngl = tmp_path / "missing_readnetwork.bngl" + bngl.write_text(""" +begin model +end model + +readNetwork({file=>"missing.net"}) +""") + + with pytest.raises(RuntimeError, match=r"Failed to read \.net file"): + bionetgen.load(str(bngl)).execute() + + def test_readmodel_alias_loads_bngl_data(self, tmp_path): + imported = tmp_path / "imported.bngl" + imported.write_text(""" +begin model +begin parameters + k 0.2 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 7 +end seed species +end model +""") + bngl = tmp_path / "readmodel.bngl" + bngl.write_text(""" +begin model +end model + +readModel({file=>"imported.bngl"}) +writeModel() +""") + + bionetgen.load(str(bngl)).execute() + + output = tmp_path / "readmodel_out.bngl" + assert output.exists() + assert "X()" in output.read_text() + + def test_readmodel_alias_rejects_missing_file(self, tmp_path): + bngl = tmp_path / "missing_readmodel.bngl" + bngl.write_text(""" +begin model +end model + +readModel({file=>"missing.bngl"}) +""") + + with pytest.raises(RuntimeError, match="Could not open BNGL file"): + bionetgen.load(str(bngl)).execute() + + +class TestHighLevelAPI: + def test_load_and_simulate(self, tmp_path): + bngl = tmp_path / "api.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 100 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +end model +""") + model = bionetgen.load(str(bngl)) + assert len(model.parameters) == 1 + assert len(model.reaction_rules) == 1 + + result = model.simulate(method="ode", t_end=10.0, n_steps=50) + assert result.n_steps == 51 + assert len(result.observable_names) >= 1 + + sample_times = [0.0, 0.25, 1.5, 10.0] + result = model.simulate( + method="ode", t_end=10.0, n_steps=0, sample_times=sample_times + ) + assert result.time.tolist() == sample_times + + result = model.simulate( + method="ssa", t_end=10.0, n_steps=0, sample_times=sample_times, seed=42 + ) + assert result.time.tolist() == sample_times + + nf_sample_times = [0.0, 0.25, 1.5, 10.0] + result = model.simulate( + method="nf", t_end=10.0, n_steps=0, sample_times=nf_sample_times, seed=42 + ) + assert result.time.tolist() == nf_sample_times + + with pytest.raises(ValueError, match="only for method='ssa'"): + model.simulate(method="ode", t_end=1.0, n_steps=1, max_sim_steps=1) + + for method in ["pla", "psa"]: + result = model.simulate(method=method, t_start=2.0, t_end=4.0, n_steps=2) + assert result.time[0] == pytest.approx(2.0) + assert result.time[-1] == pytest.approx(4.0) + + result = model.simulate(method="nf", t_start=1.0, t_end=2.0, n_steps=1, seed=1) + assert result.time.tolist() == pytest.approx([1.0, 2.0]) + + with pytest.raises( + ValueError, match="equilibrate must be finite and non-negative" + ): + model.simulate(method="nf", t_end=1.0, n_steps=1, equilibrate=-0.1) + + result = model.simulate( + method="nf", t_end=1.0, n_steps=1, seed=1, equilibrate=0.1 + ) + assert result.time.tolist() == pytest.approx([0.0, 1.0]) + + def test_set_parameter(self, tmp_path): + bngl = tmp_path / "param.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 100 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +end model +""") + model = bionetgen.load(str(bngl)) + model.set_parameter("k", 0.5) + + def test_action_simulation_honors_sample_times(self, tmp_path): + bngl = tmp_path / "action_controls.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 100 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +begin actions + simulate_ode({prefix=>"sample",t_start=>0,t_end=>2,sample_times=>[0,0.25,1.5,2]}) +end actions +end model +""") + + model = bionetgen.load(str(bngl)) + model.execute() + rows = [ + line.split() + for line in (tmp_path / "sample.gdat").read_text().splitlines() + if line and not line.startswith("#") + ] + assert [float(row[0]) for row in rows] == [0.0, 0.25, 1.5, 2.0] + + def test_parameter_scan_action_writes_final_observables(self, tmp_path): + bngl = tmp_path / "scan_action.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 10 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +end model + +generate_network({overwrite=>1}) +parameter_scan({method=>"ode",parameter=>"k",par_min=>0.1,par_max=>0.2,n_scan_pts=>2,par_scan_vals=>[9,10],t_end=>1,n_steps=>1}) +""") + + model = bionetgen.load(str(bngl)) + model.execute() + + scan_path = tmp_path / "scan_action_k.scan" + assert scan_path.exists() + rows = [ + line.split() + for line in scan_path.read_text().splitlines() + if line and not line.startswith("#") + ] + assert len(rows) == 2 + assert [float(row[0]) for row in rows] == pytest.approx([0.1, 0.2]) + assert float(rows[0][1]) > float(rows[1][1]) + + def test_parameter_scan_action_supports_process_isolated_workers(self, tmp_path): + bngl = tmp_path / "scan_action_parallel.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 10 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +end model + +parameter_scan({method=>"ode",parameter=>"k",par_scan_vals=>[0.1,0.2],parallel=>1,num_cores=>2,t_end=>1,n_steps=>1}) +""") + + bionetgen.load(str(bngl)).execute() + + scan_path = tmp_path / "scan_action_parallel_k.scan" + assert scan_path.exists() + rows = [ + line.split() + for line in scan_path.read_text().splitlines() + if line and not line.startswith("#") + ] + assert len(rows) == 2 + assert [float(row[0]) for row in rows] == pytest.approx([0.1, 0.2]) + assert float(rows[0][1]) > float(rows[1][1]) + for index in (1, 2): + assert ( + tmp_path + / "scan_action_parallel_k" + / f"scan_action_parallel_k_{index:05d}.gdat" + ).exists() + + def test_parameter_scan_partial_range_uses_explicit_values(self, tmp_path): + bngl = tmp_path / "scan_partial_range.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 10 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +end model + +generate_network({overwrite=>1}) +parameter_scan({method=>"ode",parameter=>"k",par_min=>0.9,par_scan_vals=>[0.1,0.2],t_end=>1,n_steps=>1}) +""") + + bionetgen.load(str(bngl)).execute() + + scan_path = tmp_path / "scan_partial_range_k.scan" + assert scan_path.exists() + rows = [ + line.split() + for line in scan_path.read_text().splitlines() + if line and not line.startswith("#") + ] + assert [float(row[0]) for row in rows] == pytest.approx([0.1, 0.2]) + + def test_parameter_scan_nf_action_writes_final_observables( + self, tmp_path, monkeypatch + ): + bngl = tmp_path / "scan_nf_action.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 10 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +end model + +parameter_scan({method=>"nf",parameter=>"k",par_scan_vals=>[0.1,0.2],t_end=>1,n_steps=>2,seed=>1}) +""") + + monkeypatch.setenv("BNG_NFSIM_REQUIRE_DIRECT", "1") + bionetgen.load(str(bngl)).execute() + + scan_path = tmp_path / "scan_nf_action_k.scan" + assert scan_path.exists() + rows = [ + line.split() + for line in scan_path.read_text().splitlines() + if line and not line.startswith("#") + ] + assert len(rows) == 2 + assert [float(row[0]) for row in rows] == pytest.approx([0.1, 0.2]) + + def test_protocol_parameter_scan_uses_explicit_values(self, tmp_path): + bngl = tmp_path / "protocol_scan.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 10 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +begin protocol + simulate({method=>"ode",t_start=>0,t_end=>1,n_steps=>1}) +end protocol +end model + +generate_network({overwrite=>1}) +parameter_scan({method=>"protocol",parameter=>"k",par_scan_vals=>[0.1,0.2]}) +""") + + model = bionetgen.load(str(bngl)) + model.execute() + + scan_path = tmp_path / "protocol_scan_k.scan" + assert scan_path.exists() + rows = [ + line.split() + for line in scan_path.read_text().splitlines() + if line and not line.startswith("#") + ] + assert len(rows) == 2 + assert [float(row[0]) for row in rows] == pytest.approx([0.1, 0.2]) + assert float(rows[0][1]) > float(rows[1][1]) + + def test_simulate_protocol_supports_direct_nf(self, tmp_path, monkeypatch): + bngl = tmp_path / "protocol_nf.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 10 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +begin protocol + simulate_nf({prefix=>"protocol_nf",t_end=>1,n_steps=>2,seed=>1}) +end protocol +end model + +simulate_protocol() +""") + + monkeypatch.setenv("BNG_NFSIM_REQUIRE_DIRECT", "1") + bionetgen.load(str(bngl)).execute() + + assert (tmp_path / "protocol_nf.gdat").exists() + assert (tmp_path / "protocol_nf.species").exists() + assert not (tmp_path / "protocol_nf.xml").exists() + + def test_simulate_protocol_applies_concentration_before_nf( + self, tmp_path, monkeypatch + ): + bngl = tmp_path / "protocol_nf_concentration.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 10 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +begin protocol + setConcentration("X()",0) + simulate_nf({prefix=>"protocol_nf_concentration",t_end=>1,n_steps=>1,seed=>1}) +end protocol +end model + +simulate_protocol() +""") + + monkeypatch.setenv("BNG_NFSIM_REQUIRE_DIRECT", "1") + bionetgen.load(str(bngl)).execute() + + rows = [ + line.split() + for line in (tmp_path / "protocol_nf_concentration.gdat") + .read_text() + .splitlines() + if line and not line.startswith("#") + ] + assert rows[-1][1] == "0.00000000e+00" + + def test_linear_parameter_sensitivity_writes_gsc_and_csc(self, tmp_path): + bngl = tmp_path / "linear_sensitivity.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 10 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +end model + +generate_network({overwrite=>1}) +LinearParameterSensitivity({t_end=>1,n_steps=>2,bump=>10,init_equil=>0,re_equil=>0,suffix=>"sens"}) +""") + + model = bionetgen.load(str(bngl)) + model.execute() + + gsc_path = tmp_path / "linear_sensitivity_k_sens.gsc" + csc_path = tmp_path / "linear_sensitivity_k_sens.csc" + assert gsc_path.exists() + assert csc_path.exists() + assert "Xtot" in gsc_path.read_text() + assert "X()" in csc_path.read_text() class TestIO: diff --git a/tests/python/test_legacy_console_streams.py b/tests/python/test_legacy_console_streams.py new file mode 100644 index 00000000..65a8893b --- /dev/null +++ b/tests/python/test_legacy_console_streams.py @@ -0,0 +1,36 @@ +"""Source-derived regression for legacy console error routing.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +BNG2 = REPO / "legacy" / "perl" / "BNG2.pl" + +pytestmark = pytest.mark.skipif( + shutil.which("perl") is None or not BNG2.is_file(), + reason="bundled Perl BNG2.pl is unavailable", +) + + +def test_legacy_console_writes_action_errors_to_stderr(tmp_path): + """The dca95a6a console fixture must not label errors as warnings.""" + + result = subprocess.run( + ["perl", str(BNG2), "--console"], + cwd=tmp_path, + input="action simulate()\ndone\n", + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + assert "ERROR:" not in result.stdout + assert "ERROR: Attempt to execute action without loading model" in result.stderr + assert ( + "WARNING: Attempt to execute action without loading model" not in result.stdout + ) diff --git a/tests/python/test_legacy_cvode_export.py b/tests/python/test_legacy_cvode_export.py new file mode 100644 index 00000000..121e7e88 --- /dev/null +++ b/tests/python/test_legacy_cvode_export.py @@ -0,0 +1,65 @@ +"""Source-derived regression for legacy CVODE operator export.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +BNG2 = REPO / "legacy" / "perl" / "BNG2.pl" + +pytestmark = pytest.mark.skipif( + shutil.which("perl") is None or not BNG2.is_file(), + reason="bundled Perl BNG2.pl is unavailable", +) + + +def test_legacy_cvode_export_translates_bngl_logical_operators(tmp_path): + """The dca95a6a fixture must emit C operators, not BNGL spellings.""" + + model = tmp_path / "issue_295_operator_export.bngl" + model.write_text( + """ +begin model + +begin parameters + k 1 +end parameters + +begin molecule types + A() +end molecule types + +begin seed species + A() 1 +end seed species + +begin reaction rules + A() -> 0 k*((1~=0)+(~k)) +end reaction rules + +generate_network({overwrite=>1}) +writeMexfile({prefix=>"issue_295_operator_export",overwrite=>1}) + +end model +""", + encoding="utf-8", + ) + + result = subprocess.run( + ["perl", str(BNG2), str(model)], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + generated_path = tmp_path / "issue_295_operator_export_cvode.c" + assert generated_path.is_file() + generated = generated_path.read_text(encoding="utf-8") + assert "1.0!=0.0" in generated + assert "(!NV_Ith_S" in generated + assert "1.0~=0.0" not in generated diff --git a/tests/python/test_legacy_function_cycles.py b/tests/python/test_legacy_function_cycles.py new file mode 100644 index 00000000..71fd37e4 --- /dev/null +++ b/tests/python/test_legacy_function_cycles.py @@ -0,0 +1,66 @@ +"""Source-derived regression for legacy named-function dependency cycles.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +BNG2 = REPO / "legacy" / "perl" / "BNG2.pl" + +pytestmark = pytest.mark.skipif( + shutil.which("perl") is None or not BNG2.is_file(), + reason="bundled Perl BNG2.pl is unavailable", +) + + +def test_legacy_named_function_cycle_has_bounded_diagnostic(tmp_path): + """The dca95a6a source fixture must fail as a cycle, not recurse forever.""" + + model = tmp_path / "issue_217_circular_functions.bngl" + model.write_text( + """ +begin model + +begin parameters + k 1 +end parameters + +begin molecule types + A() +end molecule types + +begin seed species + A() 1 +end seed species + +begin functions + g f() + f g() +end functions + +begin reaction rules + 0 -> A() f +end reaction rules + +generate_network() + +end model +""", + encoding="utf-8", + ) + + result = subprocess.run( + ["perl", str(BNG2), str(model)], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + combined = result.stdout + result.stderr + assert result.returncode != 0 + assert "Function dependency cycle" in combined + assert "Deep recursion" not in combined diff --git a/tests/python/test_legacy_macro_numeric_compare.py b/tests/python/test_legacy_macro_numeric_compare.py new file mode 100644 index 00000000..e2c73075 --- /dev/null +++ b/tests/python/test_legacy_macro_numeric_compare.py @@ -0,0 +1,14 @@ +"""Source-derived regression for the legacy MacroBNGModel site-count test.""" + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +MACRO_MODEL = REPO / "legacy" / "perl" / "Perl2" / "MacroBNGModel.pm" + + +def test_macro_site_count_uses_numeric_comparison(): + """The dca95a6a source fix must not use Perl's lexicographic ``gt``.""" + + source = MACRO_MODEL.read_text(encoding="utf-8") + assert "if ( $key=~/:/ && $val > 1 ) { $nm2_site->{$key} = $val; }" in source + assert "$val gt 1" not in source diff --git a/tests/python/test_legacy_network_graph_labels.py b/tests/python/test_legacy_network_graph_labels.py new file mode 100644 index 00000000..f7e69366 --- /dev/null +++ b/tests/python/test_legacy_network_graph_labels.py @@ -0,0 +1,44 @@ +"""Source-derived regression for legacy network-graph label trimming.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +PERL_INCLUDE = REPO / "legacy" / "perl" / "Perl2" +NETWORK_GRAPH = PERL_INCLUDE / "Visualization" / "NetworkGraph.pm" + +pytestmark = pytest.mark.skipif( + shutil.which("perl") is None or not NETWORK_GRAPH.is_file(), + reason="legacy Perl visualization modules are unavailable", +) + + +def _unprettify(value: str) -> str: + result = subprocess.run( + [ + "perl", + f"-I{PERL_INCLUDE}", + "-MVisualization::NetworkGraph", + "-e", + "print Viz::unprettify($ARGV[0]);", + value, + ], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + +def test_legacy_network_graph_trims_only_synthetic_empty_side_zeros(): + """The dca95a6a fix must preserve molecule names containing boundary zeros.""" + + assert _unprettify("0->A()") == "->A" + assert _unprettify("A()->0") == "A->" + assert _unprettify("0A()->B()") == "0A->B" + assert _unprettify("A()->B0()") == "A->B0" diff --git a/tests/python/test_legacy_output_contract.py b/tests/python/test_legacy_output_contract.py new file mode 100644 index 00000000..97f32fc2 --- /dev/null +++ b/tests/python/test_legacy_output_contract.py @@ -0,0 +1,31 @@ +"""Source-derived regression for legacy diagnostic/output contracts.""" + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +ACTION = REPO / "legacy" / "perl" / "Perl2" / "BNGAction.pm" +OUTPUT = REPO / "legacy" / "perl" / "Perl2" / "BNGOutput.pm" + + +def test_legacy_simulate_nf_diagnostic_uses_retrieve_spelling(): + """The dca95a6a source diagnostic must not retain its typo.""" + + source = ACTION.read_text(encoding="utf-8") + assert "To retrieve system state" in source + assert "To retreive system state" not in source + + +def test_legacy_output_has_checked_query_names_open_and_correct_ssc_message(): + """The dca95a6a output fixes must remain explicit in the legacy writer.""" + + source = OUTPUT.read_text(encoding="utf-8") + assert 'print STDOUT "\\n Writing SSC cfg file \\n";' in source + assert "Writting SSC cfg file" not in source + assert ( + "open( Q_Mscript, '>', $q_mscript )\n" + ' or return "Couldn\'t open $q_mscript for writing: $!\\n";' + ) in source + assert "the nanmes of all species" not in source + assert "the names of all species" in source + assert "the names of the seed species" in source + assert "the names of the seed speceis" not in source diff --git a/tests/python/test_legacy_pattern_xml.py b/tests/python/test_legacy_pattern_xml.py new file mode 100644 index 00000000..c40491ba --- /dev/null +++ b/tests/python/test_legacy_pattern_xml.py @@ -0,0 +1,101 @@ +"""Source-derived regressions for legacy pattern modifiers in XML output.""" + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +BNG2 = REPO / "legacy" / "perl" / "BNG2.pl" + +pytestmark = pytest.mark.skipif( + shutil.which("perl") is None or not BNG2.is_file(), + reason="bundled Perl BNG2.pl is unavailable", +) + + +def _run_legacy_xml(model_text: str, output_name: str, tmp_path: Path) -> str: + model = tmp_path / f"{output_name}.bngl" + model.write_text(model_text, encoding="utf-8") + result = subprocess.run( + ["perl", str(BNG2), str(model)], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + + output = tmp_path / f"{output_name}.xml" + assert output.is_file() + return output.read_text(encoding="utf-8") + + +def test_legacy_xml_escapes_pattern_quantifier_relations(tmp_path): + """The dca95a6a quantifier fixture must produce well-formed XML.""" + + generated = _run_legacy_xml( + """ +begin model + +begin molecule types + R() +end molecule types + +begin seed species + R() 10 +end seed species + +begin observables + Species R5 R<4 +end observables + +writeXML({prefix=>"issue_234_pattern_quantifier",overwrite=>1}) + +end model +""", + "issue_234_pattern_quantifier", + tmp_path, + ) + + assert 'relation="<" quantity="4"' in generated + assert 'relation="<" quantity="4"' not in generated + + +def test_legacy_xml_accepts_whitespace_and_no_whitespace_matchonce(tmp_path): + """The dca95a6a modifier fixture must retain both MatchOnce attributes.""" + + generated = _run_legacy_xml( + """ +begin model + +begin molecule types + A(b,b) + B(a) +end molecule types + +begin seed species + A(b,b) 10 + B(a) 20 +end seed species + +begin observables + Molecules A_space A(b!+) {MatchOnce} + Molecules A_nospace A(b!+){MatchOnce} +end observables + +begin reaction rules + A(b) + B(a) <-> A(b!1).B(a!1) 1, 0.1 +end reaction rules + +writeXML({prefix=>"issue_312_matchonce",overwrite=>1}) + +end model +""", + "issue_312_matchonce", + tmp_path, + ) + + assert generated.count('matchOnce="1"') == 2 diff --git a/tests/python/test_legacy_security_contract.py b/tests/python/test_legacy_security_contract.py new file mode 100644 index 00000000..d0a57d56 --- /dev/null +++ b/tests/python/test_legacy_security_contract.py @@ -0,0 +1,90 @@ +"""Source-derived security contract for bundled BNG2 MacroBNGModel.""" + +from pathlib import Path +import re + +REPO = Path(__file__).resolve().parents[2] +MACRO_MODEL = REPO / "legacy" / "perl" / "Perl2" / "MacroBNGModel.pm" + + +# The final tree at Sentinel commit cdbd6ee9 is an empty duplicate of +# 2b95afe9. The expected calls below are the security payload from 2b95afe9, +# combined with the already-landed .rab diagnostic fix from b13533cc. +EXPECTED_EXPLICIT_OPEN_CALLS = ( + ("WFILEbngl", ">", "$file"), + ("WFILErec", ">", "$recfile"), + ("WFILEpar", ">", "$parfile"), + ("FH", "<", "$bnglfile"), + ("WFILEpar", ">", "$parfile"), + ("WFILEmacr", ">", "$macrfile"), + ("RFILEpar", "<", "$parfile"), + ("RFILEspec2", "<", "$spec2file"), + ("RFILErules", "<", "$rulesfile"), + ("RFILEobser", "<", "$obserfile"), + ("WFILErules", ">", "$rulesfile"), + ("RFILErec", "<", "$recfile"), + ("WFILEobser", ">", "$obserfile"), + ("WFILEspec2", ">", "$spec2file"), +) + + +def _explicit_open_pattern(handle: str, mode: str, path: str) -> re.Pattern[str]: + return re.compile( + rf"open\s*\(\s*{re.escape(handle)}\s*,\s*" + rf"{re.escape(chr(34) + mode + chr(34))}\s*,\s*" + rf"{re.escape(path)}\s*\)" + ) + + +def test_legacy_macro_uses_explicit_open_modes_for_all_file_paths(): + source = MACRO_MODEL.read_text(encoding="utf-8") + + missing = [ + (handle, mode, path) + for handle, mode, path in EXPECTED_EXPLICIT_OPEN_CALLS + if not _explicit_open_pattern(handle, mode, path).search(source) + ] + assert not missing, f"missing explicit-mode open calls: {missing}" + + forbidden = ( + 'open (WFILEbngl, ">$file")', + "open (WFILErec, $recfile)", + "open (WFILEpar, $parfile)", + "open(FH, $bnglfile)", + "open (WFILEmacr, $macrfile)", + "open (RFILEpar, $parfile)", + "open (RFILEspec2, $spec2file)", + "open (RFILErules, $rulesfile)", + "open (RFILEobser, $obserfile)", + "open (WFILErules, $rulesfile)", + "open (RFILErec, $recfile)", + "open (WFILEobser, $obserfile)", + "open (WFILEspec2, $spec2file)", + ) + present = [call for call in forbidden if call in source] + assert not present, f"filename-controlled open modes remain: {present}" + + assert 'open (WFILErab, ">", $rabfile)' in source + assert 'die "Can\'t open $rabfile: $!\\n"' in source + + +def test_legacy_macro_removes_mode_prefixes_from_path_variables(): + source = MACRO_MODEL.read_text(encoding="utf-8") + + forbidden_assignments = ( + '$bnglfile= "<${param_prefix}.bngl"', + '$recfile= ">macr_${param_prefix}.rec"', + '$parfile= ">macr_${param_prefix}.par"', + '$macrfile= ">macr_${param_prefix}.bngl"', + '$parfile= " A(b!0).B(a!1).C(c!2).A(b!1).B(a!0).C(c!2) 1 +end reaction rules + +generate_network({overwrite=>1}) +""", + encoding="utf-8", + ) + + result = subprocess.run( + ["perl", str(BNG2), "--outdir", str(tmp_path), str(model)], + cwd=tmp_path, + capture_output=True, + text=True, + timeout=30, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + network_path = tmp_path / "issue_090_implicit_bonds.net" + assert network_path.is_file() + network = network_path.read_text(encoding="utf-8") + assert "0.5*_rateLaw1" in network + assert "0.25*_rateLaw1" not in network diff --git a/tests/python/test_modern_atomizer.py b/tests/python/test_modern_atomizer.py new file mode 100644 index 00000000..eca8f516 --- /dev/null +++ b/tests/python/test_modern_atomizer.py @@ -0,0 +1,2700 @@ +"""Acceptance tests for the Python port of the Playground atomizer. + +These expectations are intentionally derived from the public TypeScript +atomizer in RuleWorld/bngplayground, rather than from BNG3's legacy +translator. Keep the fixture small so each inferred relationship is +discriminating and easy to compare with the source implementation. +""" + +from __future__ import annotations + +from collections import OrderedDict + +import pytest + +SBML_FIXTURE = """ + + + + + + + + + + + + + + + + + + + + + + + + + + 4 + + + + + + + + + + kfAB + + + + + + + kpA + + + + + +""" + + +def test_playground_structures_preserve_states_bonds_and_compartments(): + from bionetgen.atomizer.modern import Component, read_from_string + + species = read_from_string("@cell::A(site~P!1).B(bind!1)@mem") + + assert [m.name for m in species.molecules] == ["A", "B"] + assert species.molecules[0].compartment == "cell" + assert species.molecules[1].compartment == "mem" + assert species.molecules[0].components[0].active_state == "P" + assert species.molecules[0].components[0].bonds == ["1"] + assert species.molecules[1].components[0].bonds == ["1"] + assert str(species) == "A(site!1~P)@cell.B(bind!1)@mem" + + component = Component("site") + component.add_state("P") + assert component.states == ["P", "0"] + assert component.str2() == "site~P~0" + + +def test_playground_species_extend_honors_update_flag_for_equal_molecule_counts(): + from bionetgen.atomizer.modern import Component, Molecule, Species + + target = Species() + target_molecule = Molecule("A") + target_component = Component("site", states=["0"]) + target_component.set_active_state("0") + target_molecule.add_component(target_component) + target.add_molecule(target_molecule) + + incoming = Species() + incoming_molecule = Molecule("A") + incoming_component = Component("site", states=["P"]) + incoming_component.set_active_state("P") + incoming_molecule.add_component(incoming_component) + incoming.add_molecule(incoming_molecule) + + # Playground structures.ts passes `update` through to addStates in the + # equal-molecule-count branch, so update=False expands state domains while + # preserving the target's active state. + target.extend(incoming, update=False) + + assert target_component.states == ["0", "P"] + assert target_component.active_state == "0" + + +def test_playground_species_extend_preserves_repeated_component_multiplicity(): + from bionetgen.atomizer.modern import Component, Molecule, Species + + target = Species() + target_molecule = Molecule("A") + target_molecule.add_component(Component("site")) + target.add_molecule(target_molecule) + + incoming = Species() + incoming_molecule = Molecule("A") + incoming_molecule.add_component(Component("site", states=["P"])) + incoming_molecule.add_component(Component("site", states=["U"])) + incoming.add_molecule(incoming_molecule) + + # The Playground implementation compares component-name Counters and + # appends the missing repeated site instead of collapsing it by name. + target.extend(incoming) + + assert [component.name for component in target_molecule.components] == [ + "site", + "site", + ] + + +def test_playground_molecule_extend_updates_last_duplicate_component(): + from bionetgen.atomizer.modern import Component, Molecule + + target = Molecule("A") + first = Component("site", states=["first"]) + first.set_active_state("first") + second = Component("site", states=["second"]) + second.set_active_state("second") + target.add_component(first) + target.add_component(second) + + incoming = Molecule("A") + incoming.add_component(Component("site", states=["P"])) + + # The Playground Molecule.extend implementation builds a Map by name; + # repeated target components therefore resolve to the last occurrence. + target.extend(incoming) + + assert [component.active_state for component in target.components] == [ + "first", + "P", + ] + + +def test_playground_species_extend_unequal_counts_only_updates_states(): + from bionetgen.atomizer.modern import Component, Molecule, Species + + target = Species() + target_molecule = Molecule("A") + target_component = Component("site", bonds=["1"], states=["0"]) + target_component.set_active_state("0") + target_molecule.add_component(target_component) + target.add_molecule(target_molecule) + + incoming = Species() + incoming_molecule = Molecule("A") + incoming_component = Component("site", bonds=["2"], states=["P"]) + incoming_component.set_active_state("P") + incoming_molecule.add_component(incoming_component) + incoming.add_molecule(incoming_molecule) + incoming.add_molecule(Molecule("B")) + + # Playground structures.ts does not merge bonds in this branch. It passes + # update through to addState and deep-copies a newly seen molecule. + target.extend(incoming, update=False) + + assert target_component.bonds == ["1"] + assert target_component.states == ["0", "P"] + assert target_component.active_state == "0" + assert [molecule.name for molecule in target.molecules] == ["A", "B"] + + +def test_playground_species_extend_uses_order_insensitive_molecule_match_score(): + from bionetgen.atomizer.modern import Component, Molecule, Species + + target = Species() + broad = Molecule("A") + broad_a = Component("a", bonds=["1"], states=["0"]) + broad_a.set_active_state("0") + broad.add_component(broad_a) + broad.add_component(Component("b", bonds=["2"])) + broad.add_component(Component("c", bonds=["3"])) + target.add_molecule(broad) + + narrow = Molecule("A") + narrow_a = Component("a", bonds=["3"], states=["0"]) + narrow_a.set_active_state("0") + narrow.add_component(narrow_a) + target.add_molecule(narrow) + + incoming = Species() + incoming_molecule = Molecule("A") + incoming_a = Component("a", bonds=["3"], states=["P"]) + incoming_a.set_active_state("P") + incoming_molecule.add_component(incoming_a) + incoming_molecule.add_component(Component("b", bonds=["2"])) + incoming_molecule.add_component(Component("c", bonds=["1"])) + incoming.add_molecule(incoming_molecule) + incoming.add_molecule(Molecule("B")) + incoming.add_molecule(Molecule("C")) + + # The Playground score counts included bond values, independent of order: + # [1, 2, 3] against [3, 2, 1] beats the narrower [3] candidate. + target.extend(incoming) + + assert broad_a.active_state == "P" + assert narrow_a.active_state == "0" + + +def test_playground_reaction_classification_is_stoichiometry_aware(): + from bionetgen.atomizer.modern import SBMLReaction, SBMLSpeciesReference + from bionetgen.atomizer.modern import classify_reaction + + binding = SBMLReaction( + id="r1", + name="", + reversible=False, + fast=False, + reactants=[SBMLSpeciesReference("A"), SBMLSpeciesReference("B")], + products=[SBMLSpeciesReference("D")], + modifiers=[], + kinetic_law={"math": "kf*A*B", "mathML": "", "localParameters": []}, + ) + unbinding = SBMLReaction( + id="r2", + name="", + reversible=False, + fast=False, + reactants=[SBMLSpeciesReference("D")], + products=[SBMLSpeciesReference("A"), SBMLSpeciesReference("B")], + modifiers=[], + kinetic_law={"math": "kr*D", "mathML": "", "localParameters": []}, + ) + + assert classify_reaction(binding).type == "binding" + assert classify_reaction(binding).reactants == ["A", "B"] + assert classify_reaction(unbinding).type == "unbinding" + assert classify_reaction(unbinding).products == ["A", "B"] + + +def test_playground_parser_preserves_annotations_initial_assignments_and_rates(): + from bionetgen.atomizer.modern import SBMLParser + + model = SBMLParser().parse(SBML_FIXTURE) + + assert model.id == "playground_fixture" + assert list(model.species) == ["A", "B", "C", "D"] + assert model.species["A"].initial_amount == 2 + assert model.species["B"].initial_amount_set is False + assert model.species["A"].annotations[0].resources == ["urn:miriam:uniprot:P12345"] + assert model.initial_assignments[0].symbol == "B" + assert model.initial_assignments[0].math == "4" + assert model.reactions["bind"].kinetic_law["math"] == "kf * A * B" + + +def test_playground_parser_returns_source_shaped_event_assignment_records(): + from bionetgen.atomizer.modern import SBMLEventAssignment, SBMLParser + + sbml = """ + + + + + + + + + + + + + """ + + model = SBMLParser().parse(sbml) + assignment = model.events[0].assignments[0] + + assert isinstance(assignment, SBMLEventAssignment) + assert assignment.variable == "x" + assert assignment.math == "k" + assert assignment["variable"] == "x" + assert assignment["math"] == "k" + assert assignment == ("x", "k") + + +def test_playground_parser_preserves_compartment_type_reference(): + from bionetgen.atomizer.modern import SBMLParser + + sbml = """ + + + + + + + """ + + model = SBMLParser().parse(sbml) + + assert model.compartments["cell"].compartment_type == "cytosol" + + +def test_playground_parser_preserves_modifier_species_records(): + from bionetgen.atomizer.modern import SBMLParser + + sbml = """ + + + + + + + + + + + + + + + + """ + + model = SBMLParser().parse(sbml) + + assert model.reactions["r"].modifiers[0].species == "E" + + +def test_playground_parser_disambiguates_duplicate_global_parameters(): + """Mirror the reference parser's duplicate-id recovery contract.""" + + from bionetgen.atomizer.modern import SBMLParser + from bionetgen.atomizer.modern.helpers import logger + + sbml = """ + + + + + + + + + + """ + + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(True) + try: + model = SBMLParser().parse(sbml) + messages = logger.getMessagesByLevel("WARNING") + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + assert list(model.parameters) == ["k", "k_2"] + assert model.parameters["k"].value == pytest.approx(2) + assert model.parameters["k_2"].value == pytest.approx(3) + duplicate_warnings = [ + warning + for warning in model.import_warnings + if warning["category"] == "duplicateParameter" + ] + assert len(duplicate_warnings) == 1 + assert ( + 'Duplicate parameter id "k" remapped to "k_2"' + in duplicate_warnings[0]["message"] + ) + assert len(messages) == 1 + assert messages[0].code == "SBM010" + assert messages[0].message.endswith('Duplicate parameter id "k" remapped to "k_2"') + + +def test_playground_parser_normalizes_duplicate_parameter_name_aliases_in_math(): + """Reference aliases must follow the canonical parameter through every math field.""" + + from bionetgen.atomizer.modern import SBMLParser + + sbml = """ + + + + + + + + + + + + + + + + + same_value_alias + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + model = SBMLParser().parse(sbml) + + assert model.initial_assignments[0].math == "k_2" + assert model.rules[0].math == "k + k_2" + assert model.function_definitions["f"].math == "k" + assert model.events[0].trigger == "k > 0" + assert model.events[0].delay == "k_2" + assert model.events[0].assignments == [("x", "k")] + assert model.reactions["r"].kinetic_law.math == "k + k + k_2" + assert model.reactions["local_alias_precedence"].kinetic_law.math == "local_rate" + + +def test_playground_parser_standardizes_parameter_ids_before_formula_aliasing(): + from bionetgen.atomizer.modern import SBMLParser + + sbml = """ + + + + + + + + + + + + + + timek + + + + + + + """ + + model = SBMLParser().parse(sbml) + + assert list(model.parameters) == ["time_id", "k"] + assert model.parameters["time_id"].id == "time_id" + reaction = model.reactions["r"] + assert reaction.kinetic_law is not None + assert [parameter.id for parameter in reaction.kinetic_law.local_parameters] == [ + "time_id" + ] + assert reaction.kinetic_law.math == "time_id + k" + + +def test_playground_parser_suffixes_duplicate_local_parameter_ids(): + from bionetgen.atomizer.modern import SBMLParser + + sbml = """ + + + + + + + + + + + timealternate_time + + + + + + + """ + + model = SBMLParser().parse(sbml) + reaction = model.reactions["r"] + assert reaction.kinetic_law is not None + assert [parameter.id for parameter in reaction.kinetic_law.local_parameters] == [ + "time_id", + "time_id_2", + ] + assert reaction.kinetic_law.math == "time_id + time_id_2" + + +def test_playground_writer_groups_duplicate_seed_patterns(): + from bionetgen.atomizer.modern import ( + Molecule, + SBMLModel, + SBMLSpecies, + SeedSpeciesEntry, + Species, + SpeciesCompositionTable, + write_seed_species, + ) + + structure = Species() + structure.add_molecule(Molecule("A")) + seeds = [ + SeedSpeciesEntry(structure.copy(), "1", "cell", "A"), + SeedSpeciesEntry(structure.copy(), "2", "cell", "B"), + SeedSpeciesEntry(structure.copy(), "3", "cell", "C"), + SeedSpeciesEntry(structure.copy(), "4", "cell", "D"), + ] + + lines, species_to_pattern, pattern_to_id = write_seed_species( + seeds, + SpeciesCompositionTable(), + SBMLModel( + id="duplicate_seed", + species={ + "C": SBMLSpecies(id="C", constant=True), + "D": SBMLSpecies(id="D", constant=True), + }, + ), + ) + + assert lines == ["@cell:M_A() 1", "$@cell:M_A() 3"] + assert species_to_pattern == { + "A": "@cell:M_A()", + "B": "@cell:M_A()", + "C": "@cell:M_A()", + "D": "@cell:M_A()", + } + assert pattern_to_id == {"@cell:M_A()": "A"} + + +def test_playground_parser_reports_model_summary(): + from bionetgen.atomizer.modern import SBMLParser + from bionetgen.atomizer.modern.helpers import logger + + logger.clear() + logger.setLevel("INFO") + logger.setQuietMode(True) + try: + model = SBMLParser().parse(SBML_FIXTURE) + messages = logger.getMessagesByLevel("INFO") + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + assert len(model.species) == 4 + assert len(model.reactions) == 2 + assert [(message.code, message.message) for message in messages] == [ + ("SBM004", "Parsed SBML model: 4 species, 2 reactions") + ] + + +def test_playground_parser_emits_import_warning_codes_and_counts(): + from bionetgen.atomizer.modern import SBMLParser + from bionetgen.atomizer.modern.helpers import logger + + sbml = """ + + + + + + + + + """ + + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(True) + try: + model = SBMLParser().parse(sbml) + warnings = logger.getMessagesByLevel("WARNING") + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + assert len(model.import_warnings) == 1 + assert [(message.code, message.message) for message in warnings] == [ + ( + "SBM022", + "[constraint] 2 SBML constraint element(s) present; " + "constraints are not enforced during simulation. (x2)", + ) + ] + + +def test_playground_parser_surfaces_fast_and_reaction_conversion_diagnostics(): + from bionetgen.atomizer.modern import SBMLParser + + model = SBMLParser().parse( + SBML_FIXTURE.replace('fast="false"', 'fast="true" conversionFactor="cf"', 1) + ) + + assert model.reactions["bind"].fast is True + assert model.reactions["bind"].conversion_factor == "cf" + assert any( + warning["category"] == "fastReaction" + and "ordinary reaction" in warning["message"] + for warning in model.import_warnings + ) + assert any( + warning["category"] == "conversionFactor" + and "captured but not applied" in warning["message"] + for warning in model.import_warnings + ) + + +def test_playground_parser_reports_unsupported_packages_events_and_constraints(): + from bionetgen.atomizer.modern import SBMLParser + + sbml = """ + + + + + + + + + + + + + + + + + + """ + + model = SBMLParser().parse(sbml) + warnings = model.import_warnings + + assert any( + w["category"] == "package:fbc" and w["severity"] == "dropped" for w in warnings + ) + assert any( + w["category"] == "package:layout" and w["severity"] == "info" for w in warnings + ) + assert any( + w["category"] == "event" and w["severity"] == "dropped" for w in warnings + ) + assert any( + w["category"] == "algebraicRule" and w["severity"] == "dropped" + for w in warnings + ) + assert any( + w["category"] == "constraint" and w["severity"] == "info" for w in warnings + ) + + +def test_playground_parser_preserves_mathml_numeric_and_function_semantics(): + from bionetgen.atomizer.modern import SBMLParser + + sbml = """ + + + + + + 12 + + + + fx + + + + t + 23 + + + + + + """ + + model = SBMLParser().parse(sbml) + rules = {rule.variable: rule.math for rule in model.rules} + + assert rules["ratio"] == "(1 / 2) / 3.141592653589793" + assert rules["call"] == "f(x)" + assert rules["clock"] == "time * (2 * 10^(3))" + + +def test_playground_parser_reports_lossy_mathml_constants_and_operators(): + from bionetgen.atomizer.modern import SBMLParser + + sbml = """ + + + + + + + + x + + + + + """ + + model = SBMLParser().parse(sbml) + warnings = [ + warning for warning in model.import_warnings if warning.category == "mathml" + ] + + assert {(warning.message, warning.severity) for warning in warnings} == { + ( + " constant encountered in math; emitted as a large finite value.", + "approximated", + ), + ( + " used in math; emitted as factorial(x), which the engine may not support.", + "approximated", + ), + } + + +def test_playground_sct_infers_complexes_and_named_modifications(): + from bionetgen.atomizer.modern import SBMLParser + from bionetgen.atomizer.modern import build_species_composition_table + + model = SBMLParser().parse(SBML_FIXTURE) + sct = build_species_composition_table(model, atomize=True) + + assert sct.entries["A"].is_elemental is True + assert sct.entries["B"].is_elemental is True + assert sct.entries["D"].is_elemental is False + assert sct.entries["D"].components == ["A", "B"] + assert "b1" in { + component.name + for molecule in sct.entries["D"].structure.molecules + for component in molecule.components + } + assert sct.entries["C"].is_elemental is False + assert sct.entries["C"].structure.molecules[0].get_component("phosphorylation") + assert ( + sct.entries["C"] + .structure.molecules[0] + .get_component("phosphorylation") + .active_state + == "P" + ) + + +def test_playground_collision_discriminator_preserves_distinct_species(): + from bionetgen.atomizer.modern import ( + Component, + Molecule, + SBMLModel, + SBMLSpecies, + Species, + SpeciesCompositionTable, + SCTEntry, + disambiguate_colliding_species, + ) + + def make_species(): + result = Species() + molecule = Molecule("ERK") + molecule.add_component(Component("site", states=["P"])) + result.add_molecule(molecule) + return result + + entries = OrderedDict( + [ + ( + "cyto", + SCTEntry( + structure=make_species(), + components=["ERK"], + sbml_id="cyto", + is_elemental=True, + modifications={}, + weight=1, + bonds=[], + ), + ), + ( + "nuc", + SCTEntry( + structure=make_species(), + components=["ERK"], + sbml_id="nuc", + is_elemental=True, + modifications={}, + weight=1, + bonds=[], + ), + ), + ] + ) + model = SBMLModel( + id="m", + name="m", + compartments=OrderedDict(), + species=OrderedDict( + [ + ("cyto", SBMLSpecies(id="cyto", name="ERK", compartment="cell")), + ("nuc", SBMLSpecies(id="nuc", name="ERK", compartment="cell")), + ] + ), + parameters=OrderedDict(), + reactions=OrderedDict(), + rules=[], + function_definitions=OrderedDict(), + events=[], + initial_assignments=[], + species_by_compartment=OrderedDict(), + unit_definitions=OrderedDict(), + ) + sct = SpeciesCompositionTable( + entries=entries, + dependencies=OrderedDict(), + reverse_dependencies=OrderedDict(), + sorted_species=["cyto", "nuc"], + weights=[("cyto", 1), ("nuc", 1)], + ) + + assert disambiguate_colliding_species(sct, model) == 2 + assert ( + sct.entries["cyto"].structure.molecules[0].get_component("__sp").active_state + == "cyto" + ) + assert ( + sct.entries["nuc"].structure.molecules[0].get_component("__sp").active_state + == "nuc" + ) + + +def test_playground_atomizer_generates_flat_and_atomized_bngl(): + from bionetgen.atomizer.modern import Atomizer + + flat = Atomizer(atomize=False).atomize(SBML_FIXTURE) + atomized = Atomizer(atomize=True).atomize(SBML_FIXTURE) + + for result in (flat, atomized): + assert result.success is True + assert "begin model" in result.bngl + assert "begin parameters" in result.bngl + assert "begin seed species" in result.bngl + assert "begin reaction rules" in result.bngl + assert "bind" in result.bngl + assert "phosphorylation" in result.bngl + + assert "A()" in flat.bngl or "M_A" in flat.bngl + assert "b1" in atomized.bngl + assert "~P" in atomized.bngl + + +def test_playground_atomizer_reports_lifecycle_diagnostics(): + from bionetgen.atomizer.modern import Atomizer + from bionetgen.atomizer.modern.helpers import logger + + logger.clear() + try: + result = Atomizer( + atomize=False, + log_level="INFO", + quiet_mode=True, + ).atomize(SBML_FIXTURE) + messages = [ + message + for message in logger.getMessages() + if message.code.startswith("ATM") + ] + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + assert result.success is True + assert [(message.code, message.message) for message in messages] == [ + ("ATM003", "Parsing SBML model..."), + ("ATM004", 'Model "Playground fixture": 4 species, 2 reactions'), + ("ATM005", "Building species composition table..."), + ("ATM006", "Found 4 molecule types"), + ("ATM007", "Found 4 seed species"), + ("ATM008", "Generating BNGL model..."), + ("ATM009", "BNGL generation complete"), + ] + + +def test_playground_atomizer_result_returns_structured_log_records(): + from bionetgen.atomizer.modern import Atomizer + from bionetgen.atomizer.modern.helpers import LogMessage, logger + + logger.clear() + try: + result = Atomizer(log_level="INFO", quiet_mode=True).atomize(SBML_FIXTURE) + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + assert result.success is True + assert result.log + assert all(isinstance(message, LogMessage) for message in result.log) + assert [ + (message.code, message.message) + for message in result.log + if message.code.startswith("ATM") + ] == [ + ("ATM003", "Parsing SBML model..."), + ("ATM004", 'Model "Playground fixture": 4 species, 2 reactions'), + ("ATM005", "Building species composition table..."), + ("ATM006", "Found 4 molecule types"), + ("ATM007", "Found 4 seed species"), + ("ATM008", "Generating BNGL model..."), + ("ATM009", "BNGL generation complete"), + ] + + +def test_playground_atomizer_facade_exposes_reference_method_names(): + from bionetgen.atomizer.modern import ( + Atomizer, + sbmlToBngl, + sbmlToBnglAtomized, + sbmlToBnglFlat, + sbml_to_bngl, + sbml_to_bngl_atomized, + sbml_to_bngl_flat, + ) + + method_aliases = { + "setOptions": "set_options", + "getOptions": "get_options", + "flatTranslation": "flat_translation", + "fullAtomization": "full_atomization", + "getModel": "get_model", + "getSCT": "get_sct", + "getUniProtIds": "get_uniprot_ids", + "getDatabases": "get_databases", + "analyzeNaming": "analyze_naming", + "analyzeReactionPatterns": "analyze_reaction_patterns", + } + for reference_name, python_name in method_aliases.items(): + assert getattr(Atomizer, reference_name) is getattr(Atomizer, python_name) + + function_aliases = { + sbmlToBngl: sbml_to_bngl, + sbmlToBnglFlat: sbml_to_bngl_flat, + sbmlToBnglAtomized: sbml_to_bngl_atomized, + } + for reference_function, python_function in function_aliases.items(): + assert reference_function is python_function + + +def test_playground_atomizer_accepts_supported_source_option_spellings(): + from bionetgen.atomizer.modern import Atomizer + + # The TypeScript facade names these options useId, quietMode, logLevel, + # tEnd, and nSteps. BNG3 retains its existing snake-case option mapping + # after accepting the source spellings at the public boundary. + atomizer = Atomizer( + useId=True, + quietMode=True, + logLevel="INFO", + tEnd=7, + nSteps=11, + ) + options = atomizer.getOptions() + + assert options["use_id"] is True + assert options["quiet_mode"] is True + assert options["log_level"] == "INFO" + assert options["t_end"] == 7 + assert options["n_steps"] == 11 + assert all( + name not in options + for name in ( + "useId", + "namingConventions", + "quietMode", + "logLevel", + "tEnd", + "nSteps", + ) + ) + + atomizer.setOptions({"useId": False, "tEnd": 3}) + options = atomizer.get_options() + assert options["use_id"] is False + assert options["t_end"] == 3 + + +def test_playground_atomizer_uses_source_naming_conventions_option(): + from bionetgen.atomizer.modern import Atomizer, NamingConventions + + conventions = NamingConventions(patterns={("+ _", "+ P"): "Custom"}) + atomizer = Atomizer(namingConventions=conventions, quiet_mode=True) + + assert atomizer.getOptions()["naming_conventions"] is conventions + result = atomizer.atomize(SBML_FIXTURE) + analysis = atomizer.analyzeNaming() + + assert result.success is True + assert analysis["pairClassification"] == {"Custom": [("A", "A_P")]} + + +def test_playground_atomizer_returns_databases_container_in_result(): + from bionetgen.atomizer.modern import Atomizer, Databases + + atomizer = Atomizer(quiet_mode=True) + assert isinstance(atomizer.getDatabases(), Databases) + + result = atomizer.atomize(SBML_FIXTURE) + + assert result.success is True + assert isinstance(result.database, Databases) + assert result.database is atomizer.getDatabases() + assert result.database.getRawDatabase() == {} + assert result.database.getLabelDictionary() == {} + assert result.database.getTranslator() == {} + + atomizer.clear() + assert isinstance(atomizer.getDatabases(), Databases) + + +def test_playground_atomizer_uses_large_flat_fast_path(monkeypatch): + from bionetgen.atomizer.modern import Atomizer + from bionetgen.atomizer.modern.helpers import logger + + monkeypatch.setenv("ATOMIZER_LARGE_FASTPATH", "1") + monkeypatch.setenv("ATOMIZER_FASTPATH_MIN_SPECIES", "1") + monkeypatch.setenv("ATOMIZER_FASTPATH_MIN_REACTIONS", "999999") + monkeypatch.setenv("ATOMIZER_FASTPATH_MIN_SBML_CHARS", "999999999") + logger.clear() + try: + result = Atomizer( + atomize=False, + log_level="INFO", + quiet_mode=True, + ).atomize(SBML_FIXTURE) + messages = [ + message + for message in logger.getMessages() + if message.code.startswith("ATM") + ] + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + assert result.success is True + assert [(message.code, message.message) for message in messages] == [ + ("ATM003", "Parsing SBML model..."), + ("ATM004", 'Model "Playground fixture": 4 species, 2 reactions'), + ( + "ATM011", + "Large-model flat fast path enabled " + "(species=4, reactions=2, sbmlChars=2850)", + ), + ] + assert "M_A()" in result.bngl + assert "ATM005" not in {message.code for message in messages} + + +def test_playground_math_rewrites_match_writer_contract(): + from bionetgen.atomizer.modern import convert_math_expression + + assert convert_math_expression("pow(x, y)") == "((x)^(y))" + assert convert_math_expression("sqrt(x)") == "((x)^(1/2))" + assert convert_math_expression("exp(x)") == "(2.71828182845905^(x))" + assert convert_math_expression("log(x)") == "ln(x)" + assert convert_math_expression("log10(x)") == "(ln(x)/2.302585093)" + assert convert_math_expression("abs(x)") == "if(x>=0,x,-(x))" + assert ( + convert_math_expression("piecewise(v1, c1, v2, c2)") + == "if(c1, v1, if(c2, v2, 0))" + ) + assert convert_math_expression("gt(a, b)") == "(a > b)" + assert convert_math_expression("pi * exponentiale * true * false") == ( + "3.14159265358979 * 2.71828182845905 * 1 * 0" + ) + + +def test_playground_writer_emits_zero_argument_functions_and_assignment_rules(): + from bionetgen.atomizer.modern import ( + SBMLFunctionDefinition, + SBMLModel, + SBMLParameter, + SBMLRule, + SBMLSpecies, + Atomizer, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="rules", + name="rules", + species=OrderedDict( + [ + ( + "A", + SBMLSpecies( + id="A", name="A", initial_amount=1, initial_amount_set=True + ), + ) + ] + ), + parameters=OrderedDict([("k", SBMLParameter(id="k", value=2))]), + function_definitions=OrderedDict( + [("f", SBMLFunctionDefinition(id="f", name="f", math="k + 1"))] + ), + rules=[SBMLRule(type="assignment", variable="v", math="f")], + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "f() = k + 1" in bngl + assert "v() = (k + 1)" in bngl + + +def test_playground_writer_emits_simple_assignment_rules_as_observables(): + from bionetgen.atomizer.modern import ( + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLRule, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="assignment_observable", + species=OrderedDict( + [ + ("A", SBMLSpecies(id="A", name="A", initial_amount=3)), + ("B", SBMLSpecies(id="B", name="B", initial_amount=4)), + ] + ), + parameters=OrderedDict([("k", SBMLParameter(id="k", value=1))]), + rules=[SBMLRule(type="assignment", variable="total", math="A + 2 * B")], + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("B")], + kinetic_law=SBMLKineticLaw("k * total"), + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "Molecules total " in bngl + assert "Molecules total_amt " in bngl + assert "total() =" not in bngl + assert "r: M_A() -> M_B() k * total" in bngl + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_inlines_reaction_flux_ids_in_assignment_rules(): + from bionetgen.atomizer.modern import ( + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLRule, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="reaction_flux_rule", + species=OrderedDict( + [ + ("A", SBMLSpecies(id="A", initial_amount=3)), + ("P", SBMLSpecies(id="P", initial_amount=0)), + ] + ), + parameters=OrderedDict([("k", SBMLParameter(id="k", value=2))]), + reactions=OrderedDict( + [ + ( + "rA", + SBMLReaction( + id="rA", + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("P")], + kinetic_law=SBMLKineticLaw("k * A"), + ), + ) + ] + ), + rules=[SBMLRule(type="assignment", variable="flux", math="rA")], + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "flux() = rA" not in bngl + assert "flux() = (2 * _c_A())" in bngl + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_function_inlining_handles_nested_arguments_and_parameters(): + from bionetgen.atomizer.modern import SBMLFunctionDefinition, extend_function + + functions = OrderedDict( + [ + ( + "square", + SBMLFunctionDefinition( + id="square", name="square", arguments=["x"], math="x * x" + ), + ) + ] + ) + assert extend_function("square(5) + square(a)", {}, functions) == ( + "((5) * (5)) + ((a) * (a))" + ) + aliased_functions = OrderedDict( + [ + ( + "square_id", + SBMLFunctionDefinition( + id="square_id", name="square", arguments=["x"], math="x * x" + ), + ) + ] + ) + assert extend_function("square_id(5)", {}, aliased_functions) == "((5) * (5))" + assert extend_function("k1 * x + k2", {"k1": 10, "k2": "20.5"}, {}) == ( + "10 * x + 20.5" + ) + + zero_argument = OrderedDict( + [ + ( + "kPlus", + SBMLFunctionDefinition(id="kPlus", name="kPlus", math="1.5"), + ) + ] + ) + assert extend_function("kPlus() * 2", {}, zero_argument) == "(1.5) * 2" + + +def test_playground_bngl_function_maps_species_compartments_and_saturation_rates(): + from bionetgen.atomizer.modern import bngl_function + + assert ( + bngl_function( + "Sat(k1, Km)", + "rxn1", + ["S1"], + species_with_conc_functions={"S1"}, + sbml_to_bngl_id={"S1": "S1"}, + ) + == "Sat(k1, Km, S1_amt)" + ) + assert ( + bngl_function( + "Sat(k1, Km, S1)", + "rxn1", + ["S1"], + species_with_conc_functions={"S1"}, + sbml_to_bngl_id={"S1": "S1"}, + ) + == "((k1) * Sat(S1_amt, Km, S1_amt))" + ) + assert ( + bngl_function( + "Hill(k1, Km, S1, n)", + "rxn1", + ["S1"], + species_with_conc_functions={"S1"}, + sbml_to_bngl_id={"S1": "S1"}, + ) + == "((k1) * (S1_amt)^(n) / ((Km)^(n) + (S1_amt)^(n)))" + ) + assert bngl_function("k1 * cytosol", "rxn", [], compartments=["cytosol"]) == ( + "k1 * __compartment_cytosol__" + ) + assert ( + bngl_function("rxn1 * 2", "rxn", [], reaction_dict={"rxn1": "R1_net"}) + == "netflux_R1_net * 2" + ) + assert ( + bngl_function("piecewise(v1, c1, v2, c2)", "rxn", []) + == "if(c1, v1, if(c2, v2, 0))" + ) + + +def test_playground_writer_preserves_nonlinear_rate_laws_and_amount_observables(): + from bionetgen.atomizer.modern import ( + SBMLCompartment, + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="rates", + name="rates", + compartments=OrderedDict( + [("cell", SBMLCompartment(id="cell", size=2, spatial_dimensions=3))] + ), + species=OrderedDict( + [ + ( + "S", + SBMLSpecies( + id="S", + name="S", + compartment="cell", + initial_amount=10, + initial_amount_set=True, + ), + ), + ( + "P", + SBMLSpecies(id="P", name="P", compartment="cell"), + ), + ] + ), + parameters=OrderedDict( + [ + ("kcat", SBMLParameter(id="kcat", value=3)), + ("Km", SBMLParameter(id="Km", value=2)), + ] + ), + reactions=OrderedDict( + [ + ( + "sat", + SBMLReaction( + id="sat", + reactants=[SBMLSpeciesReference("S")], + products=[SBMLSpeciesReference("P")], + kinetic_law=SBMLKineticLaw("Sat(kcat, Km)"), + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "Sat(kcat, Km, S_amt)" in bngl + assert "Species S_amt" in bngl + assert "_c_S() = S / __compartment_cell__" in bngl + + +def test_playground_writer_strips_leading_compartment_factor_from_mass_action(): + from bionetgen.atomizer.modern import ( + SBMLCompartment, + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="leading_compartment_factor", + compartments=OrderedDict( + [("cell", SBMLCompartment(id="cell", size=2, spatial_dimensions=3))] + ), + species=OrderedDict( + [ + ( + "A", + SBMLSpecies( + id="A", + name="A", + compartment="cell", + initial_amount=4, + initial_amount_set=True, + ), + ), + ("P", SBMLSpecies(id="P", name="P", compartment="cell")), + ] + ), + parameters=OrderedDict([("k", SBMLParameter(id="k", value=3))]), + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("P")], + kinetic_law=SBMLKineticLaw("cell * k * A"), + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + reaction_line = next(line for line in bngl.splitlines() if line.startswith(" r:")) + assert reaction_line.endswith(" k") + assert "__compartment_cell__ * k" not in reaction_line + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +@pytest.mark.parametrize("rate", ["k * A * cell", "k * A / cell"]) +def test_playground_writer_strips_internal_compartment_factor_from_mass_action(rate): + from bionetgen.atomizer.modern import ( + SBMLCompartment, + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="internal_compartment_factor", + compartments=OrderedDict( + [("cell", SBMLCompartment(id="cell", size=2, spatial_dimensions=3))] + ), + species=OrderedDict( + [ + ( + "A", + SBMLSpecies( + id="A", + name="A", + compartment="cell", + initial_amount=4, + initial_amount_set=True, + ), + ), + ("P", SBMLSpecies(id="P", name="P", compartment="cell")), + ] + ), + parameters=OrderedDict([("k", SBMLParameter(id="k", value=3))]), + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("P")], + kinetic_law=SBMLKineticLaw(rate), + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + reaction_line = next(line for line in bngl.splitlines() if line.startswith(" r:")) + assert reaction_line.endswith(" k") + assert "__compartment_cell__" not in reaction_line + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_splits_reversible_net_rates_by_direction(): + from bionetgen.atomizer.modern import ( + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="reversible_net_rate", + species=OrderedDict( + [ + ("A", SBMLSpecies(id="A", name="A", initial_amount=2)), + ("B", SBMLSpecies(id="B", name="B", initial_amount=0)), + ] + ), + parameters=OrderedDict( + [ + ("kf", SBMLParameter(id="kf", value=0.5)), + ("kr", SBMLParameter(id="kr", value=0.25)), + ] + ), + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reversible=True, + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("B")], + kinetic_law=SBMLKineticLaw("kf * A - kr * B"), + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "r: M_A() <-> M_B() kf, kr" in bngl + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_falls_back_for_unsplittable_reversible_nonlinear_rate(): + from bionetgen.atomizer.modern import ( + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="reversible_nonlinear_fallback", + species=OrderedDict( + [ + ("A", SBMLSpecies(id="A", name="A", initial_amount=2)), + ("B", SBMLSpecies(id="B", name="B", initial_amount=0)), + ] + ), + parameters=OrderedDict( + [ + ("kf", SBMLParameter(id="kf", value=0.5)), + ("Km", SBMLParameter(id="Km", value=1)), + ("kr", SBMLParameter(id="kr", value=0.25)), + ] + ), + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reversible=True, + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("B")], + kinetic_law=SBMLKineticLaw("kf * A / (Km + A) - kr * B"), + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + reaction_line = next(line for line in bngl.splitlines() if line.startswith(" r:")) + assert " M_A() -> M_B() " in reaction_line + assert " <-> " not in reaction_line + assert "kf * _c_A() / (Km + _c_A()) - kr * _c_B()" in reaction_line + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_wraps_time_only_rates_in_live_functions(): + from bionetgen.atomizer.modern import ( + SBMLKineticLaw, + SBMLModel, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="time_only_rate", + species=OrderedDict([("B", SBMLSpecies(id="B", name="B"))]), + reactions=OrderedDict( + [ + ( + "light", + SBMLReaction( + id="light", + reactants=[], + products=[SBMLSpeciesReference("B")], + kinetic_law=SBMLKineticLaw("2 + time()"), + ), + ) + ] + ), + ) + + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "_trate_light() = 2 + time()" in bngl + assert "light: 0 -> M_B() _trate_light()" in bngl + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_reports_missing_kinetic_laws(): + from bionetgen.atomizer.modern import ( + SBMLModel, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + from bionetgen.atomizer.modern.helpers import logger + + model = SBMLModel( + id="missing_kinetic", + species=OrderedDict( + [ + ("A", SBMLSpecies(id="A", name="A", initial_amount=1)), + ("B", SBMLSpecies(id="B", name="B")), + ] + ), + reactions=OrderedDict( + [ + ( + "missing", + SBMLReaction( + id="missing", + name="missing", + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("B")], + ), + ) + ] + ), + ) + + sct = build_species_composition_table(model) + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(True) + try: + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + warnings = logger.getMessagesByLevel("WARNING") + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + assert "missing: M_A() -> M_B() 1" in bngl + assert [(message.code, message.message) for message in warnings] == [ + ( + "BNW011", + "Reaction missing missing kinetic law; using fallback rate 1", + ) + ] + + +def test_playground_writer_reports_nonadjacent_transport_reactions(): + from bionetgen.atomizer.modern import ( + SBMLCompartment, + SBMLKineticLaw, + SBMLModel, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + from bionetgen.atomizer.modern.helpers import logger + + model = SBMLModel( + id="transport", + compartments=OrderedDict( + [ + ("cyto", SBMLCompartment(id="cyto", size=1)), + ("nuc", SBMLCompartment(id="nuc", size=1)), + ] + ), + species=OrderedDict( + [ + ( + "A", + SBMLSpecies(id="A", name="A", compartment="cyto", initial_amount=1), + ), + ("B", SBMLSpecies(id="B", name="B", compartment="nuc")), + ] + ), + reactions=OrderedDict( + [ + ( + "transport", + SBMLReaction( + id="transport", + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("B")], + kinetic_law=SBMLKineticLaw("k"), + ), + ) + ] + ), + ) + + sct = build_species_composition_table(model) + logger.clear() + logger.setLevel("INFO") + logger.setQuietMode(True) + try: + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + messages = logger.getMessagesByLevel("INFO") + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + assert "transport: M_A()@cyto -> M_B()@nuc k" in bngl + assert [(message.code, message.message) for message in messages] == [ + ("BNW004", "Transport reaction transport: B moves from cyto to nuc") + ] + + +def test_playground_writer_removes_repeated_site_statistical_factors(): + from bionetgen.atomizer.modern import ( + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="statistical_factor", + species=OrderedDict( + [ + ("A", SBMLSpecies(id="A", name="A(x,x)", initial_amount=1)), + ("B", SBMLSpecies(id="B", name="B", initial_amount=0)), + ] + ), + parameters=OrderedDict([("k", SBMLParameter(id="k", value=1))]), + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("B")], + kinetic_law=SBMLKineticLaw("2 * k * A"), + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + reaction_line = next(line for line in bngl.splitlines() if line.startswith(" r:")) + assert reaction_line.endswith(" k") + assert "2 * k" not in reaction_line + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_renames_keyword_colliding_parameters_in_rates(): + from bionetgen.atomizer.modern import ( + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="keyword_parameter", + species=OrderedDict([("A", SBMLSpecies(id="A", name="A", initial_amount=1))]), + parameters=OrderedDict([("max", SBMLParameter(id="max", value=2))]), + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reactants=[SBMLSpeciesReference("A")], + products=[], + kinetic_law=SBMLKineticLaw("max*A"), + ), + ) + ] + ), + ) + + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "max_id 2" in bngl + assert "r: M_A() -> 0 max_id" in bngl + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_curates_nonfinite_parameter_values(): + from bionetgen.atomizer.modern import ( + SBMLModel, + SBMLParameter, + SBMLSpecies, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="nonfinite_parameters", + species=OrderedDict([("A", SBMLSpecies(id="A", initial_amount=1))]), + parameters=OrderedDict( + [ + ("kInf", SBMLParameter(id="kInf", value=float("inf"))), + ("kNaN", SBMLParameter(id="kNaN", value=float("nan"))), + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "kInf 1e20" in bngl + assert "kNaN 0" in bngl + parameters = bngl.split("begin parameters", 1)[1].split("end parameters", 1)[0] + assert " inf" not in parameters.lower() + assert " nan" not in parameters.lower() + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_curates_nonfinite_compartment_sizes(): + from bionetgen.atomizer.modern import ( + SBMLCompartment, + SBMLModel, + SBMLSpecies, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="nonfinite_compartment", + compartments=OrderedDict( + [("cell", SBMLCompartment(id="cell", size=float("nan")))] + ), + species=OrderedDict( + [ + ( + "A", + SBMLSpecies( + id="A", + name="A", + compartment="cell", + initial_amount=1, + initial_amount_set=True, + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "cell 3 1" in bngl + assert "nan" not in bngl.lower() + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_keys_observables_by_sbml_id_not_display_name(): + from bionetgen.atomizer.modern import ( + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="display_name", + species=OrderedDict( + [ + ( + "S", + SBMLSpecies( + id="S", + name="Substrate", + initial_amount=1, + initial_amount_set=True, + ), + ), + ("P", SBMLSpecies(id="P", name="Product")), + ] + ), + parameters=OrderedDict( + [ + ("kcat", SBMLParameter(id="kcat", value=3)), + ("Km", SBMLParameter(id="Km", value=2)), + ] + ), + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reactants=[SBMLSpeciesReference("S")], + products=[SBMLSpeciesReference("P")], + kinetic_law=SBMLKineticLaw("Sat(kcat,Km)"), + ), + ) + ] + ), + ) + + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "Species S_amt" in bngl + assert "Species Substrate_amt" not in bngl + assert "Sat(kcat, Km, S_amt)" in bngl + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_preserves_constant_species_as_fixed_seeds(): + from bionetgen.atomizer.modern import ( + SBMLModel, + SBMLSpecies, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="fixed_species", + species=OrderedDict( + [ + ( + "A", + SBMLSpecies( + id="A", + name="A", + initial_amount=4, + initial_amount_set=True, + boundary_condition=True, + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "$M_A() 4" in bngl + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_folds_expression_seed_amounts_before_emission(): + from bionetgen.atomizer.modern import ( + SBMLCompartment, + SBMLInitialAssignment, + SBMLModel, + SBMLParameter, + SBMLSpecies, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="expression_seed", + compartments=OrderedDict( + [("cell", SBMLCompartment(id="cell", size=2, spatial_dimensions=3))] + ), + species=OrderedDict( + [ + ( + "A", + SBMLSpecies( + id="A", + name="A", + compartment="cell", + initial_amount=0, + initial_amount_set=False, + ), + ) + ] + ), + parameters=OrderedDict([("k", SBMLParameter(id="k", value=2))]), + initial_assignments=[SBMLInitialAssignment(symbol="A", math="power(k, 2)")], + ) + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "@cell:M_A() 8" in bngl + assert "power(" not in bngl + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_seed_selection_honors_explicit_unset_initial_values(): + from bionetgen.atomizer.modern import ( + SBMLModel, + SBMLSpecies, + build_species_composition_table, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="explicit_unset_seed", + species=OrderedDict( + [ + ( + "A", + SBMLSpecies( + id="A", + name="A", + initial_amount=5, + initial_amount_set=False, + ), + ), + ( + "B", + SBMLSpecies( + id="B", + name="B", + initial_concentration=7, + initial_concentration_set=False, + ), + ), + ] + ), + ) + sct = build_species_composition_table(model) + seeds = get_seed_species(sct, model) + + assert [seed.concentration for seed in seeds] == ["0", "0"] + + +def test_playground_event_actions_fold_constants_and_retain_unsupported_events(): + from bionetgen.atomizer.modern import SBMLEvent + from bionetgen.atomizer.modern.events import ( + EventTranslationContext, + parse_time_threshold, + synthesize_event_actions, + ) + + assert parse_time_threshold("geq(time, 5)") == "5" + assert parse_time_threshold("(2 + 3) <= time") == "2 + 3" + + context = EventTranslationContext( + resolve_species_pattern=lambda variable: ( + "@cell:M_S()" if variable == "S" else None + ), + resolve_param=lambda variable: {"k0": 3, "cell": 2}.get(variable), + is_param=lambda variable: variable == "k", + method="ode", + base_t_end=10, + base_steps=20, + ) + result = synthesize_event_actions( + [ + SBMLEvent( + id="dose", + trigger="geq(time, 5)", + delay="1", + assignments=[("S", "2"), ("k", "k0 + 1")], + ), + SBMLEvent( + id="conditional", + trigger="geq(S, 3)", + assignments=[("S", "0")], + ), + ], + context, + ) + + assert result.converted == 1 + assert result.actions_block is not None + assert 'setConcentration("@cell:M_S()", "2")' in result.actions_block + assert 'setParameter("k", "4")' in result.actions_block + assert "t_end=>6" in result.actions_block + assert result.untranslated[0][0].id == "conditional" + assert "not a simple time threshold" in result.untranslated[0][1] + + +def test_playground_event_actions_use_source_half_up_step_rounding(): + from bionetgen.atomizer.modern import SBMLEvent + from bionetgen.atomizer.modern.events import ( + EventTranslationContext, + synthesize_event_actions, + ) + + context = EventTranslationContext( + resolve_species_pattern=lambda _variable: None, + resolve_param=lambda _variable: None, + is_param=lambda variable: variable == "k", + base_t_end=2, + base_steps=5, + ) + result = synthesize_event_actions( + [ + SBMLEvent( + id="half_step", + trigger="geq(time, 1)", + assignments=[("k", "3")], + ) + ], + context, + ) + + assert result.actions_block is not None + assert "t_end=>1, n_steps=>3" in result.actions_block + assert "t_end=>2, n_steps=>3" in result.actions_block + + +def test_playground_atomizer_emits_event_actions_and_diagnostics_in_bngl(): + from bionetgen.atomizer.modern import ( + SBMLEvent, + SBMLParser, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLParser().parse(SBML_FIXTURE) + model.events = [ + SBMLEvent( + id="dose", + trigger="geq(time, 5)", + assignments=[("A", "3")], + ), + SBMLEvent( + id="state_dependent", + trigger="geq(A, 2)", + assignments=[("A", "0")], + ), + ] + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "begin actions" in bngl + assert 'setConcentration("@cell:M_A()", "3")' in bngl + assert "Events NOT simulated" in bngl + assert "state_dependent" in bngl + + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_name_standardization_handles_sbml_symbols_and_keywords(): + from bionetgen.atomizer.modern import standardize_name + + assert standardize_name("time") == "time_id" + assert standardize_name("A+B") == "AplB" + assert standardize_name("A/B") == "A_B" + assert standardize_name("αβ") == "ab" + assert standardize_name("7 days") == "_7_days" + + +def test_playground_parser_scales_declared_units_and_records_audit_warning(): + from bionetgen.atomizer.modern import ( + SBMLParser, + build_species_composition_table, + get_seed_species, + ) + + sbml = """ + + + + + + + + + + + + + + + + + + + + """ + + model = SBMLParser().parse(sbml) + + assert model.substance_units == "mmol" + assert model.volume_units == "litre" + assert model.parameters["k"].value == pytest.approx(0.004) + assert model.species["A"].initial_amount == pytest.approx(0.003) + assert model.species["C"].initial_concentration == pytest.approx(0.002) + assert model.compartments["cell"].size == pytest.approx(2) + assert any(w["category"] == "units" for w in model.import_warnings) + + sct = build_species_composition_table(model) + seeds = {seed.sbml_id: seed.concentration for seed in get_seed_species(sct, model)} + assert seeds["C"] == "(0.002 * __Avogadro__ * __compartment_cell__)" + + +def test_playground_parser_extracts_canonical_sbml_multi_as_comment_only(): + from bionetgen.atomizer.modern import ( + SBMLParser, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + sbml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + model = SBMLParser().parse(sbml) + + assert model.multi_molecule_types == ["A(bind)"] + assert model.multi_complex_patterns == ["A(bind!1).A(bind!1)"] + assert model.multi_seed_patterns == [] + assert any(w["category"] == "package:multi" for w in model.import_warnings) + + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + assert "# A(bind)" in bngl + assert "# A(bind!1).A(bind!1)" in bngl + assert "not yet fed into the simulated network" in bngl + + +def test_playground_writer_applies_uniform_sbml_conversion_factor_to_flux(): + from bionetgen.atomizer.modern import ( + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="conversion", + name="conversion", + species=OrderedDict( + [ + ("A", SBMLSpecies(id="A", name="A", initial_amount=4)), + ("P", SBMLSpecies(id="P", name="P")), + ] + ), + parameters=OrderedDict([("cf", SBMLParameter(id="cf", value=2))]), + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("P")], + kinetic_law={ + "math": "k*A", + "mathML": "", + "localParameters": [], + }, + ), + ) + ] + ), + conversion_factor="cf", + ) + + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "cf 2" in bngl + assert "2 * (k)" in bngl + + +def test_playground_writer_reports_mixed_sbml_conversion_factors(): + from bionetgen.atomizer.modern import ( + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="mixed_conversion", + species=OrderedDict( + [ + ("A", SBMLSpecies(id="A", name="A", conversion_factor="cf_a")), + ("P", SBMLSpecies(id="P", name="P", conversion_factor="cf_p")), + ] + ), + parameters=OrderedDict( + [ + ("cf_a", SBMLParameter(id="cf_a", value=2)), + ("cf_p", SBMLParameter(id="cf_p", value=3)), + ] + ), + reactions=OrderedDict( + [ + ( + "r", + SBMLReaction( + id="r", + reactants=[SBMLSpeciesReference("A")], + products=[SBMLSpeciesReference("P")], + kinetic_law={"math": "k*A", "localParameters": []}, + ), + ) + ] + ), + ) + + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "2 * (k)" not in bngl + assert any(w["category"] == "conversionFactor" for w in model.import_warnings) + assert "differing conversionFactors" in bngl + + +def test_playground_writer_synthesizes_source_sink_rules_for_species_rate_rule(): + from bionetgen.atomizer.modern import ( + SBMLModel, + SBMLParameter, + SBMLRule, + SBMLSpecies, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="rate_rule", + species=OrderedDict([("A", SBMLSpecies(id="A", name="A", initial_amount=1))]), + parameters=OrderedDict([("k", SBMLParameter(id="k", value=0.5))]), + rules=[SBMLRule(type="rate", variable="A", math="-k*A")], + ) + + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert "__rate_rule__A()" in bngl + assert "__rate_rule_pos__A()" in bngl + assert "__rate_rule_neg__A()" in bngl + assert "__rate_rule_in_A: 0 -> M_A()" in bngl + assert "__rate_rule_out_A: M_A() -> 0" in bngl + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_writer_materializes_non_species_rate_rule_targets(): + from bionetgen.atomizer.modern import ( + SBMLCompartment, + SBMLModel, + SBMLParameter, + SBMLRule, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + from bionetgen.atomizer.modern.helpers import logger + + model = SBMLModel( + id="synthetic_rate_rule", + compartments=OrderedDict( + [("cell", SBMLCompartment(id="cell", size=2, spatial_dimensions=3))] + ), + parameters=OrderedDict( + [ + ("X", SBMLParameter(id="X", value=3)), + ("k", SBMLParameter(id="k", value=0.5)), + ] + ), + rules=[SBMLRule(type="rate", variable="X", math="-k*X")], + ) + + sct = build_species_composition_table(model) + logger.clear() + logger.setLevel("INFO") + logger.setQuietMode(True) + try: + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + messages = logger.getMessagesByLevel("INFO") + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + pattern = "M___rate_rule_state__X" + assert "M___rate_rule_state__X()" in bngl + assert f"@cell:{pattern}() 3" in bngl + assert f"Species X_amt @cell:{pattern}()" in bngl + assert "__rate_rule__X() = -k*X_amt" in bngl + assert f"__rate_rule_in_X: 0 -> {pattern}@cell()" in bngl + assert f"__rate_rule_out_X: {pattern}@cell() -> 0" in bngl + assert [(message.code, message.message) for message in messages] == [ + ("BNW012", "Synthesized 1 rate-rule state species") + ] + cpp = pytest.importorskip("bionetgen._bionetgen_cpp") + cpp.parse_string(bngl) + + +def test_playground_parser_preserves_l2_rational_stoichiometry(): + from bionetgen.atomizer.modern import SBMLParser + + sbml = """ + + + + + + + + + + + + + + + + + + + + """ + + model = SBMLParser().parse(sbml) + + reference = model.reactions["rational"].reactants[0] + assert reference.stoichiometry == pytest.approx(0.5) + + +def test_playground_atomizer_preserves_zero_stoichiometry_and_rejects_unsupported_values(): + from bionetgen.atomizer.modern import ( + SBMLParser, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + sbml = """ + + + + + + + + + + + + + + + + + + + + + 2 + + + + + + + + + """ + + model = SBMLParser().parse(sbml) + + assert model.reactions["fractional"].reactants[0].stoichiometry == pytest.approx( + 0.5 + ) + assert model.reactions["zero"].reactants[0].stoichiometry == 0 + assert model.reactions["math"].reactants[0].variable_stoichiometry is True + assert any(w["category"] == "stoichiometry" for w in model.import_warnings) + + sct = build_species_composition_table(model) + bngl, _ = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + assert "fractional:" not in bngl + assert "zero: 0 -> M_B()" in bngl + assert "math: M_A() + M_A() -> M_B()" in bngl diff --git a/tests/python/test_modern_atomizer_annotations.py b/tests/python/test_modern_atomizer_annotations.py new file mode 100644 index 00000000..4f8a4d7b --- /dev/null +++ b/tests/python/test_modern_atomizer_annotations.py @@ -0,0 +1,325 @@ +"""Source-derived tests for modern Atomizer annotation handling. + +Expectations mirror the public RuleWorld/bngplayground annotation parser at +the pinned reference revision, not the implementation under test. +""" + +from __future__ import annotations + +from collections import OrderedDict +from pathlib import Path + +from bionetgen.atomizer.modern import ( + AnnotationInfo, + SBMLModel, + SBMLSpecies, + annotations_to_json, + compute_annotation_stats, + extract_uniprot_accessions, + find_equivalent_species, + get_all_annotations, + parse_resource_uri, +) + + +def _species( + species_id: str, + name: str, + annotations: list[AnnotationInfo], +) -> SBMLSpecies: + return SBMLSpecies(id=species_id, name=name, annotations=annotations) + + +def _model(*species: SBMLSpecies) -> SBMLModel: + return SBMLModel( + id="annotation_fixture", + species=OrderedDict((item.id, item) for item in species), + ) + + +def test_annotation_parser_extracts_resources_and_filters_empty_species(): + model = _model( + _species( + "s1", + "Species One", + [ + AnnotationInfo( + qualifier_type=1, + biological_qualifier=0, + resources=["urn:miriam:uniprot:P12345"], + ) + ], + ), + _species("s2", "Species Two", []), + _species( + "s3", + "Species Three", + [ + AnnotationInfo( + qualifier_type=1, + biological_qualifier=0, + resources=[ + "uniprot:Q98765", + "https://identifiers.org/go/1234567", + "https://identifiers.org/pubmed/12345", + ], + ) + ], + ), + ) + + annotations = get_all_annotations(model) + + assert list(annotations) == ["s1", "s3"] + assert [(item.database, item.identifier) for item in annotations["s1"]] == [ + ("uniprot", "P12345") + ] + assert [(item.database, item.identifier) for item in annotations["s3"]] == [ + ("uniprot", "Q98765"), + ("go", "1234567"), + ("pubmed", "12345"), + ] + + +def test_annotation_parser_handles_reference_uri_forms_and_uniprot_extraction(): + assert parse_resource_uri("https://identifiers.org/uniprot/P99999") == ( + "uniprot", + "P99999", + ) + assert parse_resource_uri("urn:miriam:uniprot:P11111") == ( + "uniprot", + "P11111", + ) + + model = _model( + _species( + "s1", + "Species One", + [ + AnnotationInfo( + qualifier_type=1, + biological_qualifier=0, + resources=["uniprot/P12345", "uniprot:Q67890"], + ) + ], + ), + _species( + "s2", + "Species Two", + [ + AnnotationInfo( + qualifier_type=1, + biological_qualifier=0, + resources=["kegg.compound/C00001"], + ) + ], + ), + ) + + assert extract_uniprot_accessions(model) == {"s1": ["P12345", "Q67890"]} + + +def test_playground_qualifier_helper_filters_resources_by_qualifier_kind(): + from bionetgen.atomizer.modern import getAnnotationsByQualifier + + annotations = [ + AnnotationInfo( + qualifier_type=1, + biological_qualifier=0, + resources=["urn:miriam:uniprot:P12345"], + ), + AnnotationInfo( + qualifier_type=1, + biological_qualifier=1, + resources=["urn:miriam:uniprot:Q67890"], + ), + AnnotationInfo( + qualifier_type=0, + model_qualifier=2, + resources=["https://identifiers.org/pubmed/12345"], + ), + ] + + assert getAnnotationsByQualifier(annotations, 0) == ["urn:miriam:uniprot:P12345"] + assert getAnnotationsByQualifier(annotations, 2, is_biological=False) == [ + "https://identifiers.org/pubmed/12345" + ] + + +def test_playground_parser_annotation_id_helpers_preserve_source_matches(): + from bionetgen.atomizer.modern import extractGOTerms, extractUniProtIds + + resources = [ + "urn:miriam:uniprot:P12345", + "https://identifiers.org/uniprot/Q67890", + "GO:0008150", + "https://identifiers.org/go/12345", + ] + + assert extractUniProtIds(resources) == ["P12345", "Q67890"] + assert extractGOTerms(resources) == ["GO:0008150", "GO:12345"] + + +def test_annotation_parser_groups_only_identity_qualifiers(): + model = _model( + _species( + "s1", + "Long Species Name", + [ + AnnotationInfo( + qualifier_type=1, + biological_qualifier=1, + resources=["uniprot:P12345"], + ), + AnnotationInfo( + qualifier_type=1, + biological_qualifier=0, + resources=["uniprot:P12345"], + ), + ], + ), + _species( + "s2", + "S", + [ + AnnotationInfo( + qualifier_type=1, + biological_qualifier=3, + resources=["https://identifiers.org/uniprot/P12345"], + ) + ], + ), + _species( + "s3", + "Other", + [ + AnnotationInfo( + qualifier_type=1, + biological_qualifier=1, + resources=["uniprot:P12345"], + ) + ], + ), + ) + + equivalent = find_equivalent_species(model) + + assert equivalent == {"uniprot:P12345": ["s1", "s2"]} + + +def test_annotation_parser_emits_reference_json_and_stats(): + model = _model( + _species( + "s1", + "", + [ + AnnotationInfo( + qualifier_type=1, + biological_qualifier=0, + resources=["uniprot:P12345"], + ) + ], + ), + _species("s2", "Species Two", []), + ) + + annotation_map = get_all_annotations(model) + payload = annotations_to_json(model, annotation_map) + + assert '"name": "s1"' in payload + assert '"identifier": "P12345"' in payload + assert compute_annotation_stats(model).annotated_species == 1 + assert compute_annotation_stats(model).annotation_count == 1 + + +def test_atomizer_annotation_payload_matches_reference_shape(): + from bionetgen.atomizer.modern import Atomizer + + sbml = """ + + + + + + + + + + + + + + """ + + result = Atomizer(annotation=True).atomize(sbml) + + assert result.success is True + assert set(result.annotation) == {"species", "reactions", "compartments"} + assert set(result.annotation["species"]) == {"A"} + assert result.annotation["species"]["A"]["annotations"][0]["resources"] == [ + "uniprot:P12345" + ] + + +def test_bng_xml_converter_preserves_reference_sections_and_bonds(): + from bionetgen.atomizer.modern import convert_bng_xml_to_bngl + + xml = Path(__file__).parent / "test" / "test.xml" + bngl = convert_bng_xml_to_bngl(xml.read_text(encoding="utf-8")) + + assert "begin parameters" in bngl + assert "begin molecule types" in bngl + assert "begin seed species" in bngl + assert "begin reaction rules" in bngl + assert "kon 10" in bngl + assert "X(y,p~0)" in bngl + assert "X(y!1,p~0).Y(x!1)" in bngl + + +def test_bng_xml_converter_reports_source_diagnostics(): + from bionetgen.atomizer.modern import convert_bng_xml_to_bngl + from bionetgen.atomizer.modern.helpers import logger + + xml = """ + + + + + + + + + + + + + + + + + + """ + + logger.clear() + logger.setLevel("INFO") + logger.setQuietMode(True) + try: + bngl = convert_bng_xml_to_bngl(xml) + messages = [ + message + for message in logger.getMessages() + if message.code.startswith("BNGXML") + ] + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) + + assert "MM(vmax,(km * cell * NA))" in bngl + assert [(message.code, message.message) for message in messages] == [ + ("BNGXML002", "Scaled MM constant for cell: (km * cell * NA)"), + ("BNGXML001", "Converted BNG SBML to BNGL (fallback)"), + ] diff --git a/tests/python/test_modern_atomizer_core.py b/tests/python/test_modern_atomizer_core.py new file mode 100644 index 00000000..84997bae --- /dev/null +++ b/tests/python/test_modern_atomizer_core.py @@ -0,0 +1,452 @@ +"""Source-derived contracts for the Playground atomization-core facade.""" + +import pytest + +from bionetgen.atomizer.modern import ( + addToDependencyGraph, + add_to_dependency_graph, + analyze_reactions, + analyze_naming_conventions, + build_species_composition_table, + classify_reaction, + defineEditDistanceMatrix, + findLongestSubstring, + topological_sort, +) +from bionetgen.atomizer.modern.helpers import logger +from bionetgen.atomizer.modern.types import ( + SBMLModel, + SBMLKineticLaw, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, +) + + +def test_dependency_graph_insertion_is_unique_and_accepts_scalar_or_list(): + graph = {} + + addToDependencyGraph(graph, "complex", ["A", "B", "A"]) + add_to_dependency_graph(graph, "complex", "B") + + assert graph == {"complex": ["A", "B"]} + + +def test_edit_distance_result_keeps_full_matrix_and_legacy_two_value_unpacking(): + result = defineEditDistanceMatrix(["A", "A_P", "far"], 2) + + assert result.matrix == [[0, 2, 3], [2, 0, 3], [3, 3, 0]] + assert result.pairs == [("A", "A_P")] + assert result.differences == [["+ _", "+ P"]] + + pairs, differences = result + assert pairs == result.pairs + assert differences == result.differences + + +def test_longest_substring_alias_matches_playground_name(): + assert findLongestSubstring("abc", "zbcx") == "bc" + + +def test_playground_infer_modification_returns_named_result(): + from bionetgen.atomizer.modern import infer_modification + + result = infer_modification("A_P", ["A"]) + + assert result.base == "A" + assert result.modification == "Phosphorylation" + assert result.confidence == pytest.approx(1 / 3) + base, modification, confidence = result + assert (base, modification, confidence) == ( + result.base, + result.modification, + result.confidence, + ) + + +@pytest.mark.parametrize( + ("suffix", "expected"), + [ + ("phos", "Phosphorylation"), + ("phospho", "Phosphorylation"), + ("_PP", "Double-Phosphorylation"), + ("ubiq", "Ubiquitination"), + ("_ub", "Ubiquitination"), + ("acet", "Acetylation"), + ("meth", "Methylation"), + ("active", "Activation"), + ("inact", "Inactivation"), + ("_bound", "Binding"), + ("_cyt", "Localization"), + ("_dimer", "Dimerization"), + ("3", "Trimerization"), + ], +) +def test_playground_default_naming_patterns_cover_source_words(suffix, expected): + from bionetgen.atomizer.modern import infer_modification + + result = infer_modification("A" + suffix, ["A"]) + + assert result.modification == expected + + +def test_playground_naming_conventions_export_and_custom_patterns_contract(): + from bionetgen.atomizer.modern import ( + DEFAULT_ATOMIZER_OPTIONS, + DEFAULT_NAMING_CONVENTIONS, + NamingConventions, + ) + + assert isinstance(DEFAULT_NAMING_CONVENTIONS, NamingConventions) + assert DEFAULT_ATOMIZER_OPTIONS["naming_conventions"] is ( + DEFAULT_NAMING_CONVENTIONS + ) + assert DEFAULT_NAMING_CONVENTIONS.modificationList is ( + DEFAULT_NAMING_CONVENTIONS.modification_list + ) + assert DEFAULT_NAMING_CONVENTIONS.reactionSite is ( + DEFAULT_NAMING_CONVENTIONS.reaction_site + ) + assert DEFAULT_NAMING_CONVENTIONS.reactionState is ( + DEFAULT_NAMING_CONVENTIONS.reaction_state + ) + assert DEFAULT_NAMING_CONVENTIONS.modification_list == [ + "Phosphorylation", + "Double-Phosphorylation", + "Triple-Phosphorylation", + "Dephosphorylation", + "Ubiquitination", + "Deubiquitination", + "Acetylation", + "Deacetylation", + "Methylation", + "Demethylation", + "Activation", + "Inactivation", + "Binding", + "Unbinding", + "Localization", + "Translocation", + "Dimerization", + "Trimerization", + "Oligomerization", + "Cleavage", + "Synthesis", + "Degradation", + ] + assert DEFAULT_NAMING_CONVENTIONS.reaction_site == [ + "phospho", + "phos", + "p", + "ub", + "ubiq", + "ac", + "acet", + "me", + "methyl", + "act", + "active", + "inact", + "inactive", + ] + assert DEFAULT_NAMING_CONVENTIONS.reaction_state == [ + "P", + "PP", + "PPP", + "U", + "UU", + "Ac", + "Me", + "MeMe", + "A", + "I", + "0", + "1", + ] + assert DEFAULT_NAMING_CONVENTIONS.definitions == [ + {"rsi": 0, "rst": 0}, + {"rsi": 0, "rst": 1}, + {"rsi": 1, "rst": 2}, + ] + + custom = NamingConventions(patterns={("+ _", "+ P"): "Custom"}) + result = analyze_naming_conventions(["A", "A_P"], custom) + + assert result["pairClassification"] == {"Custom": [("A", "A_P")]} + assert result["patterns"] == custom.patterns + + +def test_playground_facade_exports_camel_case_core_functions(): + import bionetgen.atomizer.modern as modern + + aliases = { + "buildSpeciesCompositionTable": "build_species_composition_table", + "disambiguateCollidingSpecies": "disambiguate_colliding_species", + "getMoleculeTypes": "get_molecule_types", + "getSeedSpecies": "get_seed_species", + "analyzeReactions": "analyze_reactions", + "analyzeNamingConventions": "analyze_naming_conventions", + "topologicalSort": "topological_sort", + "classifyReaction": "classify_reaction", + "reconcileSCT": "reconcile_sct", + } + + assert all( + hasattr(modern, camel) and getattr(modern, camel) is getattr(modern, snake) + for camel, snake in aliases.items() + ) + + +def test_playground_data_contract_exposes_camel_case_fields(): + from bionetgen.atomizer.modern import ( + AtomizerResult, + SBMLCompartment, + SBMLEvent, + SBMLKineticLaw, + SBMLModel, + SBMLParameter, + SBMLReaction, + SBMLSpecies, + SBMLSpeciesReference, + ) + + compartment = SBMLCompartment(id="cell", spatial_dimensions=2, size_set=True) + assert compartment.spatialDimensions == 2 + assert compartment.sizeSet is True + compartment.sizeSet = False + assert compartment.size_set is False + + parameter = SBMLParameter(id="k", value=2) + species = SBMLSpecies( + id="A", + initial_concentration=3, + initial_amount=4, + has_only_substance_units=True, + initial_amount_set=True, + initial_concentration_set=False, + sbo_term="SBO:0000252", + conversion_factor="cf", + species_type="simple", + ) + assert species.initialConcentration == 3 + assert species.initialAmount == 4 + assert species.hasOnlySubstanceUnits is True + assert species.initialAmountSet is True + assert species.initialConcentrationSet is False + assert species.sboTerm == "SBO:0000252" + assert species.conversionFactor == "cf" + assert species.speciesType == "simple" + + reference = SBMLSpeciesReference( + "A", stoichiometry_set=True, variable_stoichiometry=True + ) + assert reference.stoichiometrySet is True + assert reference.variableStoichiometry is True + + kinetic_law = SBMLKineticLaw("k * A", "") + reaction = SBMLReaction(id="r", kinetic_law=kinetic_law, conversion_factor="cf") + assert kinetic_law.mathML == "" + assert kinetic_law.localParameters == kinetic_law.local_parameters + assert reaction.kineticLaw is kinetic_law + assert reaction.conversionFactor == "cf" + + event = SBMLEvent( + id="e", + use_values_from_trigger_time=False, + trigger_initial_value=False, + trigger_persistent=True, + ) + assert event.useValuesFromTriggerTime is False + assert event.triggerInitialValue is False + assert event.triggerPersistent is True + + model = SBMLModel( + id="m", + function_definitions={"f": object()}, + species_by_compartment={"cell": ["A"]}, + import_warnings=[{"category": "test"}], + ) + assert model.functionDefinitions is model.function_definitions + assert model.speciesByCompartment is model.species_by_compartment + assert model.importWarnings is model.import_warnings + + result = AtomizerResult("bngl", observable_map={"A": "A"}) + assert result.observableMap is result.observable_map + result.observableMap = {"B": "B"} + assert result.observable_map == {"B": "B"} + + +def test_playground_data_contract_exposes_missing_sbml_record_types(): + from bionetgen.atomizer.modern import ( + SBMLCompartment, + SBMLImportWarning, + SBMLModifierSpeciesReference, + ) + + compartment = SBMLCompartment(id="cell", compartment_type="cytosol") + assert compartment.compartment_type == "cytosol" + + modifier = SBMLModifierSpeciesReference("A") + assert modifier.species == "A" + + warning = SBMLImportWarning( + category="event", + message="event dropped", + count=2, + severity="dropped", + ) + assert warning.category == "event" + assert warning.message == "event dropped" + assert warning.count == 2 + assert warning.severity == "dropped" + + +def test_playground_parser_returns_structured_import_warning_records(): + from bionetgen.atomizer.modern import SBMLImportWarning, SBMLParser + + sbml = """ + + + + + + + + """ + + model = SBMLParser().parse(sbml) + + assert model.import_warnings + warning = model.import_warnings[0] + assert isinstance(warning, SBMLImportWarning) + assert warning.category == "constraint" + assert warning["category"] == warning.category + assert warning.get("severity") == warning.severity + + +def test_playground_reaction_classification_normalizes_modifier_records(): + from bionetgen.atomizer.modern import SBMLModifierSpeciesReference + + reaction = SBMLReaction( + id="r", + products=[SBMLSpeciesReference("A")], + modifiers=[SBMLModifierSpeciesReference("E")], + ) + + assert classify_reaction(reaction).modifiers == ["E"] + + +def test_playground_topological_sort_reports_dependency_cycles(): + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(True) + try: + sorted_species = topological_sort( + ["A", "B", "C"], + {"A": {"B"}, "B": {"A"}, "C": {"A"}}, + ) + warnings = logger.getMessagesByLevel("WARNING") + finally: + logger.clear() + logger.setQuietMode(False) + + assert sorted_species == ["B", "A", "C"] + assert len(warnings) == 1 + assert warnings[0].code == "DEP001" + assert "A -> B -> A" in warnings[0].message + + +def test_playground_naming_analysis_reports_summary(): + logger.clear() + logger.setLevel("INFO") + logger.setQuietMode(True) + try: + analyze_naming_conventions(["A", "A_P"]) + messages = logger.getMessagesByLevel("INFO") + finally: + logger.clear() + logger.setQuietMode(False) + + assert len(messages) == 1 + assert messages[0].code == "NAM001" + assert messages[0].message == "Naming analysis: 1 similar pairs, 1 classifications" + + +def test_playground_naming_analysis_returns_json_string_keys(): + result = analyze_naming_conventions(["A", "A_P"]) + + assert result["keys"] == ['["+ _","+ P"]'] + + +def test_playground_reaction_analysis_reports_summary(): + model = SBMLModel( + id="model", + species={ + "A": SBMLSpecies(id="A", name="A"), + "B": SBMLSpecies(id="B", name="B"), + "AB": SBMLSpecies(id="AB", name="AB"), + }, + reactions={ + "bind": SBMLReaction( + id="bind", + reactants=[SBMLSpeciesReference("A"), SBMLSpeciesReference("B")], + products=[SBMLSpeciesReference("AB")], + ) + }, + ) + logger.clear() + logger.setLevel("INFO") + logger.setQuietMode(True) + try: + result = analyze_reactions(model) + messages = logger.getMessagesByLevel("INFO") + finally: + logger.clear() + logger.setQuietMode(False) + + assert result["bindingReactions"] == {"AB": ["A", "B"]} + assert len(messages) == 1 + assert messages[0].code == "RXN001" + assert messages[0].message == "Reaction analysis: 1 binding, 0 modification" + + +def test_reaction_classification_requires_reference_saturation_shape(): + simple_quotient = SBMLReaction( + id="simple_quotient", + reactants=[SBMLSpeciesReference("A"), SBMLSpeciesReference("B")], + products=[SBMLSpeciesReference("B")], + kinetic_law=SBMLKineticLaw("k / K"), + ) + saturation = SBMLReaction( + id="saturation", + reactants=[SBMLSpeciesReference("A"), SBMLSpeciesReference("B")], + products=[SBMLSpeciesReference("B")], + kinetic_law=SBMLKineticLaw("(vmax * A * B) / (Km + B)"), + ) + + assert classify_reaction(simple_quotient).type == "binding" + assert classify_reaction(saturation).type == "catalysis" + + +def test_playground_sct_builder_reports_summary(): + model = SBMLModel( + id="model", + species={"A": SBMLSpecies(id="A", name="A")}, + ) + logger.clear() + logger.setLevel("INFO") + logger.setQuietMode(True) + try: + table = build_species_composition_table(model) + messages = [ + message + for message in logger.getMessagesByLevel("INFO") + if message.code == "SCT001" + ] + finally: + logger.clear() + logger.setQuietMode(False) + + assert len(table.entries) == 1 + assert len(messages) == 1 + assert messages[0].message == "Built SCT: 1 species (1 elemental, 0 complex)" diff --git a/tests/python/test_modern_atomizer_events.py b/tests/python/test_modern_atomizer_events.py new file mode 100644 index 00000000..3a675b4e --- /dev/null +++ b/tests/python/test_modern_atomizer_events.py @@ -0,0 +1,42 @@ +"""Source-derived contracts for the Playground eventActions module.""" + + +def test_playground_event_actions_exposes_reference_names_and_result_fields(): + from bionetgen.atomizer.modern.events import ( + EventActionsResult, + EventSet, + EventTranslationContext, + foldNumeric, + fold_numeric, + parseTimeThreshold, + parse_time_threshold, + ) + + assert foldNumeric is fold_numeric + assert foldNumeric("1 + 2", lambda _identifier: None) == 3 + assert parseTimeThreshold is parse_time_threshold + assert parseTimeThreshold("time >= 2") == "2" + + context = EventTranslationContext( + resolve_species_pattern=lambda identifier: f"{identifier}()", + resolve_param=lambda _identifier: 2, + is_param=lambda _identifier: True, + ) + assert context.resolveSpeciesPattern("A") == "A()" + context.baseTEnd = 20 + context.baseSteps = 5 + assert context.base_t_end == 20 + assert context.base_steps == 5 + + event_set = EventSet("param", "k", 3, "k") + assert (event_set.kind, event_set.target, event_set.value, event_set.variable) == ( + "param", + "k", + 3, + "k", + ) + + result = EventActionsResult(None, 0, []) + assert result.actionsBlock is None + result.actionsBlock = "simulate({})" + assert result.actions_block == "simulate({})" diff --git a/tests/python/test_modern_atomizer_helpers.py b/tests/python/test_modern_atomizer_helpers.py new file mode 100644 index 00000000..b5e8119b --- /dev/null +++ b/tests/python/test_modern_atomizer_helpers.py @@ -0,0 +1,97 @@ +"""Source-derived contracts for the Playground atomizer helper surface.""" + +from __future__ import annotations + + +def test_playground_helpers_counter_and_default_dict_contract(): + from bionetgen.atomizer.modern import Counter, DefaultDict + + counts = Counter(["A", "A", "B"]) + assert counts.getCount("A") == 2 + assert counts.getCount("missing") == 0 + assert counts.mostCommon(1) == [("A", 2)] + assert counts.total() == 3 + assert sorted(counts.elements()) == ["A", "A", "B"] + + counts.subtract(["A", "C"]) + assert counts.getCount("A") == 1 + assert counts.getCount("C") == -1 + + values = DefaultDict(list) + values.get("species").append("A") + assert values.get("species") == ["A"] + assert values.get("new") == [] + assert "new" in values + + +def test_playground_helpers_string_and_math_contract(): + from bionetgen.atomizer.modern import ( + cleanParameterValue, + convertMathFunction, + levenshtein, + similarity, + standardizeName, + ) + + assert levenshtein("kitten", "sitting") == 3 + assert similarity("same", "same") == 1 + assert standardizeName("1 alpha-beta") == "_1_alpha_beta" + assert convertMathFunction("pow(a, b)") == "((a)^(b))" + assert convertMathFunction("sqrt(x)") == "((x)^(1/2))" + assert convertMathFunction("root(2, x)") == "((x)^(1/(2)))" + assert convertMathFunction("log(10, x)") == "(ln(x)/ln(10))" + assert convertMathFunction("log(x)") == "ln(x)" + assert convertMathFunction("log10(x)") == "(ln(x)/2.302585093)" + assert convertMathFunction("gt(a, b)") == "(a > b)" + assert convertMathFunction("and(a, b)") == "(a && b)" + assert convertMathFunction("not(c)") == "(!c)" + assert convertMathFunction("ceil(x)") == ("min(rint((x)+0.5),rint((x)+1))") + assert cleanParameterValue("Infinity NaN 1E-3 Vmax_2E1") == ("1e20 0 1e-3 Vmax_2E1") + + +def test_playground_pmemoize_shares_explicit_cache_keys(): + from bionetgen.atomizer.modern import pmemoize + + calls = [] + + def source_function(value): + calls.append(value) + return len(calls) + + first = pmemoize(source_function, "test_playground_shared_pmemoize_cache") + second = pmemoize(source_function, "test_playground_shared_pmemoize_cache") + + assert first("value") == 1 + assert second("value") == 1 + assert calls == ["value"] + + +def test_playground_rate_rule_prefix_contract(): + from bionetgen.atomizer.modern.rate_rule_constants import ( + RATE_RULE_META_PREFIX, + SYNTH_RATE_RULE_SPECIES_PREFIX, + ) + + assert RATE_RULE_META_PREFIX == "__rate_rule__" + assert SYNTH_RATE_RULE_SPECIES_PREFIX == "__rate_rule_state__" + + +def test_playground_helper_logger_has_source_level_filtering(): + from bionetgen.atomizer.modern import logger + + logger.clear() + logger.setLevel("DEBUG") + logger.setQuietMode(True) + try: + logger.debug("DBG001", "debug message") + logger.error("ERR001", "error message") + messages = logger.getMessages() + assert [(item.level, item.code) for item in messages] == [ + ("DEBUG", "DBG001"), + ("ERROR", "ERR001"), + ] + assert logger.hasErrors() + finally: + logger.clear() + logger.setLevel("WARNING") + logger.setQuietMode(False) diff --git a/tests/python/test_modern_atomizer_multi.py b/tests/python/test_modern_atomizer_multi.py new file mode 100644 index 00000000..6075a285 --- /dev/null +++ b/tests/python/test_modern_atomizer_multi.py @@ -0,0 +1,104 @@ +"""Source-derived tests for conservative SBML-Multi discovery boundaries.""" + +from __future__ import annotations + +from bionetgen.atomizer.modern import parse_multi_package + + +def test_parse_multi_package_empty_input_is_an_empty_result(): + result = parse_multi_package("") + + assert result.present is False + assert result.deep is False + assert result.bngl_molecule_types == [] + assert result.complex_patterns == [] + + +def test_parse_multi_package_discovers_namespaced_top_type_outside_species_list(): + xml = """ + + + + + + + + + + + + + """ + + result = parse_multi_package(xml) + + assert result.present is True + assert result.deep is False + assert result.bngl_molecule_types == ["A(site)"] + + +def test_parse_multi_package_returns_structured_warning_records(): + from bionetgen.atomizer.modern import SBMLImportWarning + + xml = """ + + + + + + """ + + result = parse_multi_package(xml) + + assert result.present is True + assert len(result.warnings) == 1 + warning = result.warnings[0] + assert isinstance(warning, SBMLImportWarning) + assert warning.category == "package:multi" + assert warning.severity == "info" + assert warning["message"] == warning.message + + +def test_parse_multi_package_matches_reference_result_field_names_and_patterns(): + xml = """ + + + + + + + + + + + + + + + + + + + + + + + + + + """ + + result = parse_multi_package(xml) + + assert result.bnglMoleculeTypes == ["A(bind)"] + pattern = result.complexPatterns[0] + assert pattern.typeId == "ABType" + assert pattern.pattern == "A(bind!1).A(bind!1)" + assert result.seedPatterns == [] diff --git a/tests/python/test_modern_atomizer_rulifier.py b/tests/python/test_modern_atomizer_rulifier.py new file mode 100644 index 00000000..2b52a9ab --- /dev/null +++ b/tests/python/test_modern_atomizer_rulifier.py @@ -0,0 +1,140 @@ +"""Source-derived tests for the modern Atomizer Rulifier surface. + +Expectations mirror RuleWorld/bngplayground's pinned Rulifier tests and data +structures, not the legacy Python atomizer. +""" + +from __future__ import annotations + +from bionetgen.atomizer.modern import ( + Action, + Component, + Molecule, + Rule, + Species, + analyze_rate_law, + build_state_transition_diagram, + collapse_redundant_rules, + extract_parameters, + find_redundant_rules, + group_by_reaction_center, +) + + +def _pattern( + molecule_name: str, + component_name: str | None = None, + component_idx: str = "", + state: str = "", +) -> Species: + species = Species() + molecule = Molecule(molecule_name) + if component_name is not None: + component = Component(component_name, component_idx) + if state: + component.active_state = state + component.states.append(state) + molecule.components.append(component) + species.molecules.append(molecule) + return species + + +def _state_rule( + label: str, + molecule_name: str = "A", + component_name: str = "b", + component_idx: str = "b_site", + from_state: str = "U", + to_state: str = "P", + context_state: str | None = None, +) -> Rule: + rule = Rule(label) + reactant = _pattern(molecule_name, component_name, component_idx, from_state) + product = _pattern(molecule_name, component_name, component_idx, to_state) + rule.add_reactant(reactant) + rule.add_product(product) + if context_state is not None: + rule.add_reactant(_pattern("Ctx", "site", "ctx", context_state)) + rule.add_product(_pattern("Ctx", "site", "ctx", context_state)) + action = Action() + action.set_action("StateChange", component_idx) + rule.add_action_list([action]) + return rule + + +def test_group_by_reaction_center_groups_same_center_and_separates_action(): + first = _state_rule("first") + second = _state_rule("second") + different = _state_rule("different") + different.actions[0].set_action("AddBond", "b_site", "other_site") + + grouped = group_by_reaction_center([first, second, different]) + + assert len(grouped) == 2 + assert any(group == [first, second] for group in grouped.values()) + + +def test_build_state_transition_diagram_matches_reference_contract(): + rule = _state_rule("state-change") + rule.add_rate("k1") + + diagram = build_state_transition_diagram([rule], "A", "b") + + assert diagram.states == {"U", "P"} + assert diagram.initial_state == "U" + assert len(diagram.transitions) == 1 + transition = diagram.transitions[0] + assert transition.from_state == "U" + assert transition.to_state == "P" + assert transition.rate == "k1" + assert transition.rule is rule + + +def test_build_state_transition_diagram_ignores_non_state_changes_and_defaults_rate(): + rule = _state_rule("state-change") + rule.actions[0].set_action("AddBond", "b_site", "other_site") + ignored = build_state_transition_diagram([rule], "A", "b") + assert ignored.states == set() + assert ignored.transitions == [] + + default_rate_rule = _state_rule("default-rate") + diagram = build_state_transition_diagram([default_rate_rule], "A", "b") + assert diagram.transitions[0].rate == "1" + + +def test_find_and_collapse_redundant_rules_preserves_generalized_rule_shape(): + first = _state_rule("first", context_state="same") + second = _state_rule("second", context_state="same") + + redundant = find_redundant_rules([first, second]) + + assert len(redundant) == 1 + assert next(iter(redundant.values())) == [first, second] + + collapsed = collapse_redundant_rules([first, second]) + assert len(collapsed) == 1 + assert collapsed[0].label == "first_generalized" + assert collapsed[0].rates == [] + + +def test_different_contexts_are_not_marked_redundant(): + first = _state_rule("first", context_state="StateA") + second = _state_rule("second", context_state="StateB") + + assert find_redundant_rules([first, second]) == {} + + +def test_extract_parameters_and_analyze_rate_law_follow_reference_rules(): + rule = _state_rule("rate") + rule.add_rate("k1 * A + exp(B) + log(C)") + + assert extract_parameters([rule]) == {"k1", "A", "B", "C"} + assert analyze_rate_law("k*A*B", ["A", "B"]) == { + "order": 2, + "mass_action": True, + "rate_constant": "k", + } + nonlinear = analyze_rate_law("k*A*(B+1)", ["A", "B"]) + assert nonlinear["order"] == 2 + assert nonlinear["mass_action"] is False + assert nonlinear["rate_constant"] is None diff --git a/tests/python/test_modern_atomizer_structures.py b/tests/python/test_modern_atomizer_structures.py new file mode 100644 index 00000000..df4c3411 --- /dev/null +++ b/tests/python/test_modern_atomizer_structures.py @@ -0,0 +1,100 @@ +"""Source-derived tests for the remaining modern Atomizer structures.""" + +from __future__ import annotations + +from bionetgen.atomizer.modern import ( + Component, + Databases, + Molecule, + Species, + States, + read_from_string, +) + + +def test_component_hash_and_remove_components_match_reference_surface(): + component = Component("site") + assert component.hash() == "site" + + molecule = Molecule("A") + first = Component("first") + second = Component("second") + molecule.components = [first, second] + molecule.remove_components([first]) + assert molecule.components == [second] + + +def test_extract_atomic_patterns_preserves_reaction_center_and_bond_context(): + species = read_from_string("A(site~P!1).B(bind!1)") + + result = species.extract_atomic_patterns("AddBond", "site", "bind") + + assert "A(site~P)" in result.atomic_patterns + assert "A(site!1).B(bind!1)" in result.atomic_patterns + assert "A(site!1).B(bind!1)" in result.reaction_center + assert "A(site~P)" in result.context + + +def test_list_of_bonds_maps_internal_component_ids_to_public_names(): + species = Species() + species.bonds = [("A_C1", "B_C2")] + + bonds = species.list_of_bonds( + {"A": "Alpha", "B": "Beta", "A_C1": "left", "B_C2": "right"} + ) + + assert bonds == { + "Alpha": {"left": [("Beta", "right")]}, + "Beta": {"right": [("Alpha", "left")]}, + } + + +def test_delete_bond_matches_playground_component_pair_semantics(): + species = read_from_string("A(site!1).B(bind!1)") + + species.delete_bond(["A", "site"]) + + assert species.molecules[0].get_component("site").bonds == [] + assert species.molecules[1].get_component("bind").bonds == ["1"] + + +def test_databases_and_states_expose_reference_containers(): + databases = Databases() + databases.add2_label_dictionary(["B", "A"], "value") + assert databases.get_label_dictionary()["A,B"] == "value" + assert databases.get_raw_database() == {} + assert databases.get_translator() == {} + + states = States("phosphorylation", "p1") + assert states.name == "phosphorylation" + assert states.idx == "p1" + + +def test_playground_structures_expose_camel_case_object_methods(): + from bionetgen.atomizer.modern import readFromString + + component = Component("site") + component.addState("P") + component.addBond(1) + + assert component.getName() == "site" + assert component.getRuleStr() == "site!1~P" + assert component.getTotalStr() == "site~P~0" + assert component.toString() == "site!1~P" + + molecule = Molecule("A") + molecule.addComponent(component) + molecule.setCompartment("cell") + + assert molecule.getComponent("site") is component + assert molecule.getBondNumbers() == [1] + assert molecule.toString(True) == "A(site!1~P)@cell" + + species = Species() + species.addMolecule(molecule) + + assert species.getMolecule("A") is molecule + assert species.getMoleculeNames() == ["A"] + assert species.getSize() == 1 + assert species.toString(True) == "A(site!1~P)@cell" + assert str(readFromString("A(site~P)")) == "A(site~P)" diff --git a/tests/python/test_modern_atomizer_uniprot.py b/tests/python/test_modern_atomizer_uniprot.py new file mode 100644 index 00000000..0e78c35b --- /dev/null +++ b/tests/python/test_modern_atomizer_uniprot.py @@ -0,0 +1,78 @@ +"""Source-derived tests for the modern Atomizer UniProt service.""" + +from __future__ import annotations + +import bionetgen.atomizer.modern.uniprot as uniprot +from bionetgen.atomizer.modern import ( + clear_uniprot_cache, + fetch_uniprot_entry, +) + + +def test_fetch_uniprot_entry_parses_reference_fields(monkeypatch): + sample = { + "primaryAccession": "P12345", + "proteinDescription": {"recommendedName": {"fullName": {"value": "Protein X"}}}, + "genes": [{"geneName": {"value": "GENEX"}}], + "organism": {"scientificName": "Homo sapiens"}, + "comments": [ + {"type": "FUNCTION", "texts": [{"value": "Does X"}]}, + { + "type": "SUBCELLULAR_LOCATION", + "subcellularLocations": [ + {"location": {"value": "Nucleus"}}, + {"location": {"value": "Cytoplasm"}}, + ], + }, + ], + "keywords": [{"value": "Kinase"}], + } + + def request_json(url, timeout): + assert url.endswith("/uniprotkb/P12345.json") + assert timeout > 0 + return True, sample + + monkeypatch.setattr(uniprot, "_request_json", request_json) + clear_uniprot_cache() + + result = fetch_uniprot_entry("p12345") + + assert result is not None + assert result.accession == "P12345" + assert result.protein_name == "Protein X" + assert result.gene_name == "GENEX" + assert result.organism == "Homo sapiens" + assert result.function == "Does X" + assert result.subcellular_location == ["Nucleus", "Cytoplasm"] + assert result.keywords == ["Kinase"] + + +def test_fetch_uniprot_entry_caches_failures_and_successes(monkeypatch): + calls = [] + + def request_json(url, timeout): + calls.append(url) + return False, None + + monkeypatch.setattr(uniprot, "_request_json", request_json) + clear_uniprot_cache() + + assert fetch_uniprot_entry("NOPE") is None + assert fetch_uniprot_entry("nope") is None + assert len(calls) == 1 + + clear_uniprot_cache() + success_calls = [] + + def successful_request(url, timeout): + success_calls.append(url) + return True, {"primaryAccession": "P11111"} + + monkeypatch.setattr(uniprot, "_request_json", successful_request) + first = fetch_uniprot_entry("P11111") + second = fetch_uniprot_entry("p11111") + + assert len(success_calls) == 1 + assert first == second + assert first.accession == "P11111" diff --git a/tests/python/test_modern_atomizer_writer_facade.py b/tests/python/test_modern_atomizer_writer_facade.py new file mode 100644 index 00000000..bd64bc0c --- /dev/null +++ b/tests/python/test_modern_atomizer_writer_facade.py @@ -0,0 +1,71 @@ +"""Source-derived contracts for the public Playground BNGL writer facade.""" + +from bionetgen.atomizer.modern import bnglReaction, bngl_reaction +from bionetgen.atomizer.modern.helpers import logger +from bionetgen.atomizer.modern.structures import read_from_string + + +def test_bngl_reaction_renders_zero_order_named_irreversible_rule(): + assert ( + bngl_reaction( + [], + [], + "k0", + {}, + reversible=False, + reaction_name="source", + ) + == "source: 0 -> 0 k0" + ) + + +def test_bngl_reaction_expands_integer_stoichiometry_and_compartment_tags(): + result = bnglReaction( + [("A", 2, "cytosol")], + [("B", 1, "cytosol")], + "k1", + {"cytosol": "@cell"}, + is_compartments=True, + reversible=False, + comment="# source", + reaction_name="r1", + ) + + assert result == "r1: A@cell + A@cell -> B@cell k1 # source" + + +def test_bngl_reaction_uses_translated_patterns_and_renumbers_bonds(): + translator = { + "A": read_from_string("A(site!7).B(site!7)"), + "C": read_from_string("C()"), + } + + result = bngl_reaction( + [("A", 1, "cytosol")], + [("C", 1, "cytosol")], + "k1", + {"cytosol": "cell"}, + translator=translator, + is_compartments=True, + reversible=True, + ) + + assert result == "A(site!1)@cell.B(site!1)@cell <-> C@cell k1" + + +def test_bngl_reaction_reports_fractional_stoichiometry_and_keeps_one_term(): + logger.clear() + result = bngl_reaction( + [("A", 0.5, "")], + [("B", 1, "")], + "k1", + {}, + reversible=False, + ) + + assert result == "A -> B k1" + assert any( + message.code == "BNW002" and "Non-integer stoichiometry" in message.message + for message in logger.getMessages() + ) + logger.clear() diff --git a/tests/python/test_modern_atomizer_writer_parameters.py b/tests/python/test_modern_atomizer_writer_parameters.py new file mode 100644 index 00000000..cde8206e --- /dev/null +++ b/tests/python/test_modern_atomizer_writer_parameters.py @@ -0,0 +1,115 @@ +"""Source-derived contracts for the Playground parameter-curation facade.""" + +from collections import OrderedDict + +from bionetgen.atomizer.modern import ( + SBMLCompartment, + SBMLFunctionDefinition, + SBMLModel, + SBMLParameter, + SBMLRule, + curateParameters, + curate_parameters, + write_functions, +) +from bionetgen.atomizer.modern.helpers import logger + + +def test_curate_parameters_rewrites_nonfinite_values_and_standardizes_ids(): + parameters = OrderedDict( + [ + ("kInf", SBMLParameter(id="kInf", value=float("inf"))), + ("kNegInf", SBMLParameter(id="kNegInf", value=float("-inf"))), + ("kNaN", SBMLParameter(id="kNaN", value=float("nan"))), + ("time", SBMLParameter(id="time", value=2.5)), + ] + ) + + logger.clear() + curated = curate_parameters(parameters) + + assert list(curated) == ["kInf", "kNegInf", "kNaN", "time_id"] + assert curated["kInf"] == "1e20" + assert curated["kNegInf"] == "-1e20" + assert curated["kNaN"] == "0" + assert curated["time_id"] == "2.5" + assert any( + message.code == "BNW001" and "kNaN" in message.message + for message in logger.getMessages() + ) + logger.clear() + + +def test_curate_parameters_camel_case_alias_matches_snake_case(): + parameters = {"k": SBMLParameter(id="k", value=3)} + + assert curateParameters(parameters) == curate_parameters(parameters) + + +def test_write_functions_can_retain_parameterized_definitions_on_request(): + """Mirror Playground writeFunctions(..., keepParameterized=true).""" + + model = SBMLModel( + id="parameterized", + function_definitions={ + "f": SBMLFunctionDefinition(id="f", arguments=["x"], math="x + 1") + }, + ) + + assert write_functions(model) == [] + assert write_functions(model, keep_parameterized=True) == [ + "f(_farg0_x) = _farg0_x + 1" + ] + + +def test_write_functions_maps_compartment_references_in_function_bodies(): + """Mirror Playground mapCompartments for definitions and assignment rules.""" + + model = SBMLModel( + id="compartment-functions", + compartments={"cytosol": SBMLCompartment(id="cytosol", size=2)}, + parameters={"k": SBMLParameter(id="k", value=3)}, + function_definitions={ + "rate": SBMLFunctionDefinition(id="rate", math="cytosol * k") + }, + rules=[SBMLRule(type="assignment", variable="flux", math="cytosol * k")], + ) + + assert write_functions(model) == [ + "rate() = __compartment_cytosol__ * k", + "flux() = __compartment_cytosol__ * 3", + ] + + +def test_write_functions_orders_assignment_rules_by_dependencies(): + """Mirror Playground's stable topological ordering of assignment rules.""" + + model = SBMLModel( + id="ordered-rules", + rules=[ + SBMLRule(type="assignment", variable="downstream", math="upstream + 1"), + SBMLRule(type="assignment", variable="upstream", math="2"), + ], + ) + + assert write_functions(model) == [ + "upstream() = 2", + "downstream() = upstream + 1", + ] + + +def test_write_functions_inlines_constant_calls_into_dynamic_bodies(): + """Mirror Playground's BNG2 function-ordering workaround.""" + + model = SBMLModel( + id="constant-function-order", + function_definitions={ + "base": SBMLFunctionDefinition(id="base", math="2"), + "dynamic": SBMLFunctionDefinition(id="dynamic", math="base() + time()"), + }, + ) + + assert write_functions(model) == [ + "base() = 2", + "dynamic() = (2) + time()", + ] diff --git a/tests/python/test_modern_atomizer_writer_rate_helpers.py b/tests/python/test_modern_atomizer_writer_rate_helpers.py new file mode 100644 index 00000000..5ace3f60 --- /dev/null +++ b/tests/python/test_modern_atomizer_writer_rate_helpers.py @@ -0,0 +1,96 @@ +"""Source-derived contracts for Playground writer rate helpers.""" + +from bionetgen.atomizer.modern import ( + ReversibleRateSplit, + SBMLFunctionDefinition, + inlineSBMLFunctions, + inline_sbml_functions, + splitReversibleRate, + split_reversible_rate, +) + + +def test_inline_sbml_functions_substitutes_formals_simultaneously(): + definitions = { + "f": SBMLFunctionDefinition(id="f", arguments=["x", "y"], math="x + y"), + } + + result = inline_sbml_functions("f(y, 2)", definitions) + + assert result == "((y) + (2))" + assert inlineSBMLFunctions("f(1, g(2))", definitions) == "((1) + (g(2)))" + + +def test_inline_sbml_functions_expands_nested_calls_and_keeps_unknown_calls(): + definitions = { + "outer": SBMLFunctionDefinition( + id="outer", arguments=["value"], math="inner(value)" + ), + "inner": SBMLFunctionDefinition(id="inner", arguments=["x"], math="x^2"), + } + + assert inline_sbml_functions("outer(a) + missing(a)", definitions) == ( + "((((a))^2)) + missing(a)" + ) + + +def test_split_reversible_rate_returns_reference_shaped_forward_and_reverse_laws(): + result = splitReversibleRate("(kf*A - kr*B)") + + assert isinstance(result, ReversibleRateSplit) + assert result.success is True + assert result.forward_rate == "kf*A" + assert result.reverse_rate == "kr*B" + assert result.forwardRate == result.forward_rate + assert result.reverseRate == result.reverse_rate + + +def test_split_reversible_rate_rejects_one_sided_and_preserves_original_input(): + result = split_reversible_rate(" kf*A ") + + assert result == ReversibleRateSplit(False, " kf*A ", "0") + + +def test_playground_writer_facade_exports_reference_function_names(): + import bionetgen.atomizer.modern as modern + + assert modern.bnglFunction is modern.bngl_function + assert modern.generateBNGL is modern.generate_bngl + + +def test_playground_generate_bngl_returns_named_generation_result(): + from collections import OrderedDict + + from bionetgen.atomizer.modern import ( + SBMLModel, + SBMLSpecies, + build_species_composition_table, + generate_bngl, + get_molecule_types, + get_seed_species, + ) + + model = SBMLModel( + id="named_generation", + species=OrderedDict( + [ + ( + "A", + SBMLSpecies( + id="A", name="A", initial_amount=1, initial_amount_set=True + ), + ) + ] + ), + ) + sct = build_species_composition_table(model) + result = generate_bngl( + model, sct, get_molecule_types(sct), get_seed_species(sct, model) + ) + + assert result.bngl.startswith("# BNGL model generated") + assert result.observableMap == result.observable_map + assert isinstance(result.warnings, list) + bngl, observable_map = result + assert bngl == result.bngl + assert observable_map == result.observable_map diff --git a/tests/python/test_parameter_scan.py b/tests/python/test_parameter_scan.py index 576f709d..ac41f9fa 100644 --- a/tests/python/test_parameter_scan.py +++ b/tests/python/test_parameter_scan.py @@ -69,6 +69,27 @@ def test_parameter_scan_log_and_linear_spacing(tmp_path): assert np.allclose(lin_scan.parameter_values, np.linspace(0.0, 1.0, 5)) +def test_parameter_scan_forwards_advanced_simulation_controls(tmp_path): + model_path = tmp_path / "decay.bngl" + _write_decay_model(model_path) + model = bionetgen.load(str(model_path)) + + scan = model.parameter_scan( + parameter="k", + values=[0.1], + method="ode", + t_end=1.0, + n_steps=0, + sample_times=[0.0, 0.25, 1.0], + max_step=0.1, + stop_if="Xtot < 0", + sparse=False, + check_product_scale=1000.0, + ) + + assert scan.results[0].time.tolist() == [0.0, 0.25, 1.0] + + def test_parameter_scan_2d_shape_and_standalone(tmp_path): model_path = tmp_path / "decay.bngl" _write_decay_model(model_path) diff --git a/tests/python/test_runner.py b/tests/python/test_runner.py index 43411e48..ee5a59ef 100644 --- a/tests/python/test_runner.py +++ b/tests/python/test_runner.py @@ -4,8 +4,9 @@ from bionetgen.modelapi.runner import run +@patch("bionetgen.modelapi.runner._load_cpp_backend", return_value=None) @patch("bionetgen.modelapi.runner.BNGCLI") -def test_runner_with_out(mock_bngcli): +def test_runner_with_out(mock_bngcli, mock_cpp_backend): mock_cli_instance = MagicMock() mock_bngcli.return_value = mock_cli_instance mock_cli_instance.result = "mock_result" @@ -20,9 +21,10 @@ def test_runner_with_out(mock_bngcli): assert result == "mock_result" +@patch("bionetgen.modelapi.runner._load_cpp_backend", return_value=None) @patch("bionetgen.modelapi.runner.BNGCLI") @patch("bionetgen.modelapi.runner.TemporaryDirectory") -def test_runner_without_out(mock_tempdir, mock_bngcli): +def test_runner_without_out(mock_tempdir, mock_bngcli, mock_cpp_backend): mock_cli_instance = MagicMock() mock_bngcli.return_value = mock_cli_instance mock_cli_instance.result = "mock_result" @@ -42,8 +44,9 @@ def test_runner_without_out(mock_tempdir, mock_bngcli): assert result == "mock_result" +@patch("bionetgen.modelapi.runner._load_cpp_backend", return_value=None) @patch("bionetgen.modelapi.runner.BNGCLI") -def test_runner_exception(mock_bngcli): +def test_runner_exception(mock_bngcli, mock_cpp_backend): mock_cli_instance = MagicMock() mock_bngcli.return_value = mock_cli_instance mock_cli_instance.run.side_effect = Exception("Test Exception") @@ -57,3 +60,15 @@ def test_runner_exception(mock_bngcli): run(inp, out=out) assert os.getcwd() == cur_dir + + +def test_runner_does_not_fallback_after_cpp_failure(): + cpp = MagicMock() + cpp.parse_file.side_effect = RuntimeError("backend parse failure") + + with patch("bionetgen.modelapi.runner._load_cpp_backend", return_value=cpp): + with patch("bionetgen.modelapi.runner.BNGCLI") as mock_bngcli: + with pytest.raises(RuntimeError, match="backend parse failure"): + run("test.bngl", out="test_out") + + mock_bngcli.assert_not_called() diff --git a/tests/python/test_sbml_import.py b/tests/python/test_sbml_import.py index 6f6a0d03..dfad3b16 100644 --- a/tests/python/test_sbml_import.py +++ b/tests/python/test_sbml_import.py @@ -4,11 +4,6 @@ pytest.importorskip("bionetgen._bionetgen_cpp") -try: - import libsbml # noqa: F401 -except ImportError: - pytest.skip("libSBML is required for SBML import tests", allow_module_level=True) - import bionetgen diff --git a/tests/python/test_sensitivity.py b/tests/python/test_sensitivity.py index 177118fa..fb55091d 100644 --- a/tests/python/test_sensitivity.py +++ b/tests/python/test_sensitivity.py @@ -51,6 +51,31 @@ def test_sensitivity_binding_signs(): assert result.matrix[1, 0] < 0 +def test_sensitivity_forwards_advanced_simulation_controls(): + builder = ModelBuilder("AdvancedSensitivity") + builder.add_parameter("k", 0.1) + builder.add_molecule_type("X()") + builder.add_seed_species("X()", 100) + builder.add_observable("Molecules", "Xtot", "X()") + builder.add_rule("X() -> 0", "k") + model = builder.build() + + result = model.sensitivity_analysis( + parameters=["k"], + observables=["Xtot"], + method="ode", + t_end=1.0, + n_steps=0, + sample_times=[0.0, 0.25, 1.0], + max_step=0.1, + stop_if="Xtot < 0", + sparse=False, + check_product_scale=1000.0, + ) + + assert result.baseline.time.tolist() == [0.0, 0.25, 1.0] + + def test_sensitivity_rejects_invalid_inputs(): builder = ModelBuilder("InvalidSensitivity") builder.add_parameter("k", 0.1) diff --git a/tests/python/test_sympy_odes.py b/tests/python/test_sympy_odes.py index 59311df7..d24010a2 100644 --- a/tests/python/test_sympy_odes.py +++ b/tests/python/test_sympy_odes.py @@ -1,13 +1,41 @@ import pytest -from unittest.mock import patch -from bionetgen.modelapi.sympy_odes import _safe_rmtree - - -def test_safe_rmtree_exception(): - with patch("shutil.rmtree") as mock_rmtree: - mock_rmtree.side_effect = Exception("Mock exception") - # Should not raise an exception - try: - _safe_rmtree("dummy_path") - except Exception as e: - pytest.fail(f"_safe_rmtree raised an exception unexpectedly: {e}") + +pytest.importorskip("bionetgen._bionetgen_cpp") +pytest.importorskip("sympy") + +from bionetgen.modelapi.sympy_odes import SympyOdes, export_sympy_odes + + +def test_export_sympy_odes_uses_modern_cpp_writer(tmp_path): + bngl = tmp_path / "sympy_decay.bngl" + bngl.write_text(""" +begin model +begin parameters + k 0.1 +end parameters +begin molecule types + X() +end molecule types +begin seed species + X() 10 +end seed species +begin observables + Molecules Xtot X() +end observables +begin reaction rules + X() -> 0 k +end reaction rules +end model +""") + output_dir = tmp_path / "mex" + + result = export_sympy_odes( + str(bngl), out_dir=str(output_dir), mex_suffix="sym", keep_files=True + ) + + assert isinstance(result, SympyOdes) + assert result.source_path == str(output_dir / "sympy_decay_sym_mex.c") + assert len(result.species) == 1 + assert len(result.params) == 1 + assert len(result.odes) == 1 + assert str(result.odes[0]) == "-p0*s0" diff --git a/tests/test_ci_contract.py b/tests/test_ci_contract.py new file mode 100644 index 00000000..3f19f17d --- /dev/null +++ b/tests/test_ci_contract.py @@ -0,0 +1,232 @@ +"""Acceptance contracts for the Python package CI installation path.""" + +import json +from pathlib import Path +import re + +from scripts.validate import load_skip_models, run_validation + +REPO = Path(__file__).resolve().parents[1] +PYPROJECT = REPO / "pyproject.toml" +CI_WORKFLOW = REPO / ".github" / "workflows" / "ci.yml" +WEEKLY_WORKFLOW = REPO / ".github" / "workflows" / "weekly.yml" +REFERENCE_EXCLUSIONS = REPO / "tests" / "validation" / "reference_exclusions.json" +VALIDATE_DIR = REPO / "tests" / "validation" / "Validate" + + +def test_pull_request_runs_keep_exact_head_evidence_available(): + """A later PR push must not cancel validation for the preceding SHA.""" + + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + assert "github.event.pull_request.head.sha" in workflow + assert re.search(r"^\s+cancel-in-progress:\s+false\s*$", workflow, re.MULTILINE) + + +def _workflow_job(name: str) -> str: + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + match = re.search( + rf"(?ms)^ {re.escape(name)}:\n(?P.*?)(?=^ [a-z0-9-]+:\n|\Z)", + workflow, + ) + assert match, f"CI must define a {name} job" + return match.group("body") + + +def _python_test_job() -> str: + return _workflow_job("python-test") + + +def _workflow_job_from(path: Path, name: str) -> str: + workflow = path.read_text(encoding="utf-8") + match = re.search( + rf"(?ms)^ {re.escape(name)}:\n(?P.*?)(?=^ [a-z0-9-]+:\n|\Z)", + workflow, + ) + assert match, f"{path.name} must define a {name} job" + return match.group("body") + + +def test_pull_request_exercises_clean_source_distribution_install(): + """PRs must exercise the sdist install path, not only an in-tree wheel.""" + + job = _workflow_job("package-smoke") + assert "needs: [python-test]" in job + assert not re.search(r"^\s+if:.*github\.event_name.*push", job, re.MULTILINE) + assert "python -m build --sdist" in job + assert "python -m venv" in job + assert "pip install --no-deps dist/*.tar.gz" in job + assert "import bionetgen" in job + assert re.search(r"/bin/bionetgen\"?\s+--version", job) + + +def test_project_declares_click_as_runtime_dependency(): + project = PYPROJECT.read_text(encoding="utf-8") + dependencies = re.search( + r"(?ms)^dependencies\s*=\s*\[(?P.*?)^\]", project + ) + assert dependencies, "pyproject.toml must declare project dependencies" + assert re.search(r"['\"]click(?:[<>=!~].*)?['\"]", dependencies.group("body")) + + +def test_python_matrix_installs_runtime_dependencies_before_no_deps_wheel(): + """No-deps wheel install must run after click is installed explicitly. + + The wheel job intentionally uses ``--no-deps``. Therefore its test + environment must install every runtime dependency required at collection + time; otherwise ``tests/python/test_cli.py`` cannot import ``click``. + """ + + job = _python_test_job() + assert "--no-deps" in job + install_lines = [ + line + for line in job.splitlines() + if re.search(r"(?:pip|python\s+-m\s+pip)\s+install", line) + ] + assert install_lines, "python-test must install package/test dependencies" + assert any(re.search(r"\bclick(?:[<>=!~].*)?\b", line) for line in install_lines), ( + "python-test no-deps wheel path must install declared click dependency" + ) + + +def test_python_tests_use_headless_isolated_matplotlib_cache(): + """The plotting test must not share a stale GUI/font cache on runners.""" + + job = _python_test_job() + assert "MPLBACKEND: Agg" in job + assert "MPLCONFIGDIR: ${{ runner.temp }}/matplotlib" in job + assert "scripts/prepare_matplotlib_cache.py" in job + + +def test_sbml_import_runs_as_a_real_isolated_ci_gate(): + """SBML coverage stays enabled while native XML libraries are isolated.""" + + job = _python_test_job() + assert "pytest tests/python/test_sbml_import.py -v --tb=short" in job + assert "--ignore=tests/python/test_sbml_import.py" in job + + +def test_msvc_parser_headers_clear_windows_macros_before_antlr(): + """Windows SDK macros must not rewrite ANTLR enum members.""" + + compat = (REPO / "cpp" / "parser" / "antlr_compat.hpp").read_text( + encoding="utf-8" + ) + assert re.search(r"#\s*undef\s+ERROR", compat) + assert re.search(r"#\s*undef\s+TRUE", compat) + assert re.search(r"#\s*undef\s+FALSE", compat) + assert re.search(r"#\s*undef\s+constant", compat) + + source = (REPO / "cpp" / "nfsim" / "NFinput" / "NFinput_fromAst.cpp").read_text( + encoding="utf-8" + ) + parser_include = source.index('#include "PatternGraphBuilder.hpp"') + prefix = source[:parser_include] + assert '#include "parser/antlr_compat.hpp"' in prefix + + +def test_weekly_cross_validation_fails_closed_on_engine_or_output_errors(): + """The claimed C++/Perl gate must not turn failed models into skips.""" + + job = _workflow_job_from(WEEKLY_WORKFLOW, "cross-validation") + assert "set -euo pipefail" in job + assert "SKIP" not in job + assert "| Failed |" in job + assert "[ \"$FAIL\" -gt 0 ]" in job + assert job.count("FAIL=$((FAIL + 1))") >= 3 + + +def test_reference_validation_can_fail_closed_on_missing_oracles(tmp_path): + """A claimed reference gate must not turn a missing .net into a skip.""" + + validate_dir = tmp_path / "Validate" + (validate_dir / "DAT_validate").mkdir(parents=True) + (validate_dir / "missing_oracle.bngl").write_text( + "begin model\nend model\n", encoding="utf-8" + ) + + results, details = run_validation( + "unused-bng-cpp", + validate_dir, + strict_references=True, + ) + + assert results == {"pass": 0, "fail": 0, "skip": 0, "error": 1} + assert details == ["ERROR missing_oracle (no reference .net)"] + + +def test_reference_ci_jobs_enable_strict_reference_validation(): + """PR and weekly reference jobs must opt into the fail-closed contract.""" + + assert "--strict-references" in _workflow_job("validation") + weekly_job = _workflow_job_from(WEEKLY_WORKFLOW, "bng-validation") + assert "--strict-references" in weekly_job + + +def test_reference_exclusion_manifest_is_explicit_and_corpus_backed(): + """Reference skips must be centralized, typed, and tied to corpus evidence.""" + + manifest = json.loads(REFERENCE_EXCLUSIONS.read_text(encoding="utf-8")) + assert manifest["schema_version"] == 1 + assert manifest["status"] == "pending-maintainer-approval" + assert set(manifest["profiles"]) == {"pull_request", "weekly"} + assert set(manifest["reasons"]) == { + "missing_reference_net", + "unsupported_native_path", + } + + pull_request = manifest["profiles"]["pull_request"] + weekly = manifest["profiles"]["weekly"] + assert len(pull_request) == len(set(pull_request)) + assert len(weekly) == len(set(weekly)) + assert set(weekly) == set(pull_request) + + missing_reference = set(manifest["reasons"]["missing_reference_net"]) + unsupported_native = set(manifest["reasons"]["unsupported_native_path"]) + assert missing_reference.isdisjoint(unsupported_native) + assert set(pull_request) == missing_reference | unsupported_native + + for model in missing_reference | unsupported_native: + assert isinstance(model, str) and model + assert (VALIDATE_DIR / f"{model}.bngl").is_file(), model + assert all( + not (VALIDATE_DIR / "DAT_validate" / f"{model}.net").is_file() + for model in missing_reference + ) + assert all( + (VALIDATE_DIR / "DAT_validate" / f"{model}.net").is_file() + for model in unsupported_native + ) + + +def test_reference_ci_jobs_consume_profiled_exclusions(): + """CI must use the committed profile instead of duplicating skip strings.""" + + validation_job = _workflow_job("validation") + weekly_job = _workflow_job_from(WEEKLY_WORKFLOW, "bng-validation") + assert "SKIP=" not in validation_job + assert "SKIP=" not in weekly_job + assert re.search( + r"--skip-file tests/validation/reference_exclusions\.json\s*\\?\s+" + r"--skip-profile pull_request", + validation_job, + ) + assert re.search( + r"--skip-file tests/validation/reference_exclusions\.json\s*\\?\s+" + r"--skip-profile weekly", + weekly_job, + ) + + +def test_validate_loads_the_committed_reference_exclusion_profile(): + """The CLI loader must expose the same ordered models as the manifest.""" + + manifest = json.loads(REFERENCE_EXCLUSIONS.read_text(encoding="utf-8")) + assert ( + load_skip_models(REFERENCE_EXCLUSIONS, "pull_request") + == manifest["profiles"]["pull_request"] + ) + assert ( + load_skip_models(REFERENCE_EXCLUSIONS, "weekly") + == manifest["profiles"]["weekly"] + ) diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 00000000..3bc649cb --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,27 @@ +"""Tests for the standalone validation harness.""" + +from __future__ import annotations + +from pathlib import Path + +from scripts.validate import copy_referenced_support_files + + +def test_validation_harness_copies_referenced_net_support_file(tmp_path): + validate_dir = tmp_path / "Validate" + dat_dir = validate_dir / "DAT_validate" + source_dir = validate_dir / "INPUT_FILES" + dat_dir.mkdir(parents=True) + source_dir.mkdir() + model = validate_dir / "model.bngl" + model.write_text('readFile({file=>"model.net"})\n', encoding="utf-8") + (dat_dir / "model.net").write_text("begin species\nend species\n", encoding="utf-8") + work_dir = tmp_path / "work" + work_dir.mkdir() + + copied = copy_referenced_support_files(model, validate_dir, dat_dir, work_dir) + + assert copied == [work_dir / "model.net"] + assert (work_dir / "model.net").read_text(encoding="utf-8") == ( + "begin species\nend species\n" + ) diff --git a/tests/validation/README.md b/tests/validation/README.md index c96cadf1..c799b740 100644 --- a/tests/validation/README.md +++ b/tests/validation/README.md @@ -4,7 +4,9 @@ Differential testing against the originals. Nothing merges until it matches. ## Oracles - **Perl** — `legacy/perl/BNG2.pl`. Truth for `.net` and ODE/SSA `.gdat`. Cached as golden so Perl is off the hot path. -- **NFsim** — native binary from the CMake `NFsim` target. Truth for network-free. Set `NFSIM_BIN`. +- **NFsim** — independently built native binary from the pinned pre-convergence + NFsim source. Truth for network-free. Set `NFSIM_BIN` to its existing path; + the harness never falls back to BNG3's embedded `NFsim` target. - **Golden** — committed under `golden/`. Regenerated only by `scripts/regen_golden.py`, reviewed, committed. Never auto-regenerated. The exact model IDs used by each tier are frozen in @@ -25,7 +27,11 @@ maintainer approval; CI does not infer an unpinned RuleHub corpus. # build first: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release && cmake --build build && pip install -e . pytest tests/validation -m smoke # Tier-S, every commit pytest tests/validation -m "parity and not slow" # full corpus -pytest tests/validation -m nf --bng-cpp build/bng_cpp +NFSIM_BIN=/absolute/path/to/pinned/native/NFsim \ + PYTHONPATH=python:build/cpp pytest tests/validation -m nf --bng-cpp build/cpp/bng_cpp +NFSIM_BIN=/absolute/path/to/pinned/native/NFsim \ + BNG_ENSEMBLE_WORKERS=8 PYTHONPATH=python:build/cpp \ + pytest tests/validation -m nf # fixed 200-seed NF gate pytest tests/validation -m export python -m tests.validation.exception_ledger --max-exceptions 1 python scripts/regen_golden.py --tier p # (re)build golden, reviewed @@ -33,7 +39,7 @@ python scripts/regen_golden.py --tier p # (re)build golden, reviewe Engine discovery: `--bng-cpp PATH` / `BNG_CPP` for the CLI; `import bionetgen` for the API. ## What each gate proves -- `test_parity_net` — WO-1a. Active expected failures come only from `exceptions.json`; each is signature-checked and an unexpected pass fails. The current ledger contains `blbr` (+26 reactions). `Motivating_example_cBNGL` was removed from the exception set when rate normalization eliminated its prior mismatch. +- `test_parity_net` — WO-1a. Active expected failures come only from `exceptions.json`; each is signature-checked and an unexpected pass fails. The current ledger is empty: `blbr` now compares equal under structural species identity, including its symmetry-heavy bond-label orientations. - `test_parity_ode` — ODE rel-err <= 1e-6 vs Perl. - `test_parity_stochastic` — seeded determinism + fixed-seed ensembles (at least 200 members per side) within mean +/- 3 SE. A single `.gdat` is never treated @@ -43,7 +49,7 @@ Engine discovery: `--bng-cpp PATH` / `BNG_CPP` for the CLI; `import bionetgen` f - `test_export_formats` — WO-5. BNG-XML/SBML valid, `.net` idempotent. ## Comparator notes -`.net` reactions are keyed by **species strings**, not indices, so networks equal up to ordering compare equal and a failed merge (the over-count) compares unequal. Verified: a duplicated reaction is detected and named. +`.net` reactions are keyed by structural species identity, not indices or raw bond-label strings. Molecule/site order and explicit bond numbers are ignored; site states, compartments, connectivity, stoichiometry, multiplicity, and rate values remain significant. A duplicated reaction is detected and named. ## Exceptions `exceptions.json` is the only expected-failure ledger. Every entry names exact tests, model, method/platform scope, tracking URL, technical reason, owner, introduction/review dates, and expected assertion signature. `exception_ledger.py` rejects incomplete, duplicate, expired, or stale references and exposes `--max-exceptions` for a non-increasing budget gate. diff --git a/tests/validation/Validate/Motivating_example_cBNGL.bngl b/tests/validation/Validate/Motivating_example_cBNGL.bngl index 295d6150..eeeeb9c9 100644 --- a/tests/validation/Validate/Motivating_example_cBNGL.bngl +++ b/tests/validation/Validate/Motivating_example_cBNGL.bngl @@ -155,12 +155,12 @@ begin reaction rules Rule23: P2 -> Sink()@CP k_P_deg DeleteMolecules # importin binds TF dimer (tends to pick up in CP, drop off in NU). - Rule24: TF(im,dna,r).TF(im,dna,r) + Im(cargo)@CP <-> TF(im!1,dna,r).TF(im!2,dna,r).Im(cargo!1!2)@CP k_Im_bind_CP, k_Im_unbind_CP - Rule25: TF(im,dna,r).TF(im,dna,r) + Im(cargo)@NU <-> TF(im!1,dna,r).TF(im!2,dna,r).Im(cargo!1!2)@NU k_Im_bind_NU, k_Im_unbind_NU + Rule24: TF(im,dna,r)@CP.TF(im,dna,r)@CP + Im(cargo)@CP <-> TF(im!1,dna,r)@CP.TF(im!2,dna,r)@CP.Im(cargo!1!2)@CP k_Im_bind_CP, k_Im_unbind_CP + Rule25: TF(im,dna,r)@NU.TF(im,dna,r)@NU + Im(cargo)@NU <-> TF(im!1,dna,r)@NU.TF(im!2,dna,r)@NU.Im(cargo!1!2)@NU k_Im_bind_NU, k_Im_unbind_NU # importin binds P1 (tends to pick up in CP, drop off in NU). - Rule26: P1(im,dna) + Im(cargo)@CP <-> P1(im!1,dna).Im(cargo!1)@CP k_Im_bind_CP, k_Im_unbind_CP - Rule27: P1(im,dna) + Im(cargo)@NU <-> P1(im!1,dna).Im(cargo!1)@NU k_Im_bind_NU, k_Im_unbind_NU + Rule26: P1(im,dna)@CP + Im(cargo)@CP <-> P1(im!1,dna)@CP.Im(cargo!1)@CP k_Im_bind_CP, k_Im_unbind_CP + Rule27: P1(im,dna)@NU + Im(cargo)@NU <-> P1(im!1,dna)@NU.Im(cargo!1)@NU k_Im_bind_NU, k_Im_unbind_NU # importin enters nuclear pore. Rule28: Im(fg) + NP(fg) <-> Im(fg!1).NP(fg!1) k_Im_enters_NP, k_Im_exits_NP diff --git a/tests/validation/compare.py b/tests/validation/compare.py index d53abdd2..f8f57255 100644 --- a/tests/validation/compare.py +++ b/tests/validation/compare.py @@ -4,17 +4,17 @@ scattered across scripts/validate_*.py. Key correctness fix over the old scripts: the .net comparator keys reactions by -their *species strings*, not by species indices. Two networks that are identical -up to species ordering must compare equal; an extra or unmerged reaction must -compare unequal. This detects the active blbr over-count (+26): the test network -carries reaction tuples that the reference (Perl) merged. +structural species identity, not by species indices or raw serialization labels. +Two networks that are identical up to species ordering, site ordering, and bond +numbering compare equal; an extra or unmerged reaction still compares unequal. """ from __future__ import annotations import re -from collections import Counter +from collections import Counter, defaultdict from dataclasses import dataclass, field +from functools import lru_cache from pathlib import Path import numpy as np @@ -26,6 +26,12 @@ def _norm(s: str) -> str: return _WS.sub(" ", s.strip()) +def _rate_definition_key(name: str) -> str: + """Normalize .net function definitions to the names used in rate fields.""" + key = _norm(name) + return key[:-2] if key.endswith("()") else key + + # --------------------------------------------------------------------------- # # .net parsing # --------------------------------------------------------------------------- # @@ -33,7 +39,7 @@ def _norm(s: str) -> str: class Network: """A parsed reaction network, comparison-ready.""" - # species index (as written) -> canonical species string + # species index (as written) -> source species string species_by_index: dict[int, str] # multiset of (sorted reactant strings, sorted product strings, rate-key) reaction_multiset: Counter @@ -46,6 +52,12 @@ class Network: # The reaction list before rate resolution, so we can re-key under a # different rate_mode without re-reading the file. _raw: list = field(default_factory=list) + # Rate-key semantics used when the parsed reaction multiset was built. + rate_mode: str = "value" + # Observable-group index -> (serialized label, species index -> weight). + # BNG2 and BNG3 use different labels for some groups; the indexed weighted + # membership is the semantic contract used by the network engine. + groups: dict[int, tuple[str, dict[int, float]]] = field(default_factory=dict) @property def n_species(self) -> int: @@ -120,6 +132,9 @@ def _resolve_rate(token: str, rate_defs: dict[str, str], rate_mode: str) -> str: # Tokenizer for simple rate arithmetic: numbers, identifiers, operators, parens. _TOKEN = re.compile(r"[A-Za-z_]\w*|\d+\.?\d*(?:[eE][+-]?\d+)?|[()+\-*/^,]|\S") +_NUMBER_TOKEN = re.compile( + r"(? float: """Evaluate a pure-numeric arithmetic expression with safe math functions.""" e = expr.replace("^", "**") - e = re.sub(r"\bln\b", "log", e) # Check that all alphabetical tokens are allowed math functions for word in re.findall(r"\b[A-Za-z_]\w*\b", e): @@ -182,6 +196,7 @@ def _safe_arith(expr: str) -> float: raise ValueError(f"unknown function or symbol {word!r}") ns = { + "ln": math.log, "log": math.log10, "log10": math.log10, "log2": math.log2, @@ -211,8 +226,88 @@ def _safe_arith(expr: str) -> float: def _canon_expr(expr: str) -> str: - """Whitespace/format-insensitive canonical form for a rate expression.""" - return re.sub(r"\s+", "", expr) + """Canonicalize harmless numeric and parenthesis serialization differences.""" + result = re.sub(r"\s+", "", expr) + + def strip_outer_parentheses(value: str) -> str: + while value.startswith("(") and value.endswith(")"): + depth = 0 + encloses_all = True + for index, char in enumerate(value): + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0 and index != len(value) - 1: + encloses_all = False + break + if not encloses_all or depth != 0: + break + value = value[1:-1] + return value + + # BNG2 and BNG3 can wrap the same function argument or whole expression + # with different numbers of redundant parentheses. + result = strip_outer_parentheses(result) + + def strip_negative_product_parentheses(value: str) -> str: + while True: + changed = False + for index in range(len(value) - 1): + if value[index : index + 2] != "-(": + continue + depth = 0 + has_top_level_product = False + matching = None + for end in range(index + 1, len(value)): + char = value[end] + if char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + matching = end + break + elif char == "*" and depth == 1: + has_top_level_product = True + if matching is not None and has_top_level_product: + value = ( + value[:index] + + "-" + + value[index + 2 : matching] + + value[matching + 1 :] + ) + changed = True + break + if not changed: + return value + + result = strip_negative_product_parentheses(result) + while True: + changed = False + for index in range(len(result) - 1): + if result[index] == "(" and result[index + 1] == "(": + inner_depth = 0 + for end in range(index + 1, len(result)): + if result[end] == "(": + inner_depth += 1 + elif result[end] == ")": + inner_depth -= 1 + if inner_depth == 0: + if end + 1 < len(result) and result[end + 1] == ")": + result = result[:index] + result[index + 1 : end + 1] + result[end + 2 :] + changed = True + break + if changed: + break + if not changed: + break + + def normalize_number(match: re.Match[str]) -> str: + normalized = format(float(match.group(0)), ".15g") + return "0" if normalized in {"-0", "-0.0"} else normalized + + return _NUMBER_TOKEN.sub(normalize_number, result) def split_species(s: str) -> list[str]: @@ -265,6 +360,273 @@ def split_sites(mol: str) -> tuple[str, list[str]]: return name, sites +@dataclass +class _SpeciesGraph: + """Small labeled graph used for species identity checks. + + Molecules and sites are vertices. Molecule-site edges retain site + membership; site-site edges retain explicit bond connectivity. Bond + numbers are deliberately absent because they are serialization labels, + not model identity. + """ + + header: tuple[str, tuple[str, ...]] + labels: list[tuple[str, ...]] + adjacency: list[list[tuple[int, str]]] + + +def _split_species_compartment(value: str) -> tuple[str, str]: + """Return ``(species-compartment, body)`` for a leading ``@C::`` prefix.""" + + if value.startswith("@"): + marker = value.find("::") + if marker > 1: + return value[1:marker], value[marker + 2 :] + return "", value + + +def _split_molecule_compartment(value: str) -> tuple[str, str]: + """Return ``(molecule, compartment)`` for a top-level ``@C`` suffix.""" + + depth = 0 + for index in range(len(value) - 1, -1, -1): + char = value[index] + if char == ")": + depth += 1 + elif char == "(": + depth -= 1 + elif char == "@" and depth == 0: + return value[:index], value[index + 1 :] + return value, "" + + +def _parse_species_graph(value: str) -> _SpeciesGraph | None: + """Parse a concrete BNGL species into a labeled graph. + + This intentionally handles concrete ``.net`` species, not arbitrary BNGL + patterns. Unknown site syntax remains part of a label instead of being + silently discarded. + """ + + value = _norm(value) + if not value: + return None + + species_compartment, body = _split_species_compartment(value) + annotations = tuple(re.findall(r"\{[^}]*\}", body)) + body = re.sub(r"\{[^}]*\}", "", body) + molecules = split_species(body) + if not molecules or any(not molecule.strip() for molecule in molecules): + return None + + labels: list[tuple[str, ...]] = [] + adjacency: list[list[tuple[int, str]]] = [] + bonds: defaultdict[str, list[int]] = defaultdict(list) + + def add_vertex(label: tuple[str, ...]) -> int: + labels.append(label) + adjacency.append([]) + return len(labels) - 1 + + def add_edge(left: int, right: int, kind: str) -> None: + adjacency[left].append((right, kind)) + adjacency[right].append((left, kind)) + + for molecule in molecules: + molecule, molecule_compartment = _split_molecule_compartment(molecule.strip()) + molecule = molecule.strip() + if not molecule: + return None + + is_constant = molecule.startswith("$") + constant = "1" if is_constant else "0" + if is_constant: + molecule = molecule[1:] + name, sites = split_sites(molecule) + if not name: + return None + + molecule_vertex = add_vertex( + ("molecule", name, molecule_compartment, constant) + ) + for site in sites: + site = site.strip() + if not site: + return None + if "!" in site: + site_body, bond = site.rsplit("!", 1) + if bond.isdigit() and int(bond) > 0: + bond_kind = "explicit" + bonds[bond].append(len(labels)) + elif bond in ("+", "?"): + bond_kind = f"wildcard:{bond}" + else: + bond_kind = f"unknown:{bond}" + else: + site_body = site + bond_kind = "free" + + if "~" in site_body: + site_name, state = site_body.split("~", 1) + else: + site_name, state = site_body, "" + if not site_name: + return None + site_vertex = add_vertex(("site", site_name, state, bond_kind)) + add_edge(molecule_vertex, site_vertex, "membership") + + for bond, endpoints in bonds.items(): + if len(endpoints) == 2: + add_edge(endpoints[0], endpoints[1], "bond") + else: + # Preserve malformed/dangling explicit bonds without treating the + # numeric label itself as identity. Valid .net files take the + # two-endpoint path above. + bond_vertex = add_vertex(("bond", str(len(endpoints)))) + for endpoint in endpoints: + add_edge(bond_vertex, endpoint, "bond") + + return _SpeciesGraph( + header=(species_compartment, annotations), + labels=labels, + adjacency=adjacency, + ) + + +def _edge_profile(graph: _SpeciesGraph, left: int, right: int) -> Counter: + return Counter(kind for endpoint, kind in graph.adjacency[left] if endpoint == right) + + +def _refined_colors(left: _SpeciesGraph, right: _SpeciesGraph) -> tuple[list[int], list[int]]: + """Compute comparable Weisfeiler-Lehman colors for two labeled graphs.""" + + def initial(graph: _SpeciesGraph, vertex: int) -> tuple: + return ( + graph.labels[vertex], + len(graph.adjacency[vertex]), + tuple( + sorted( + (kind, graph.labels[endpoint]) + for endpoint, kind in graph.adjacency[vertex] + ) + ), + ) + + signatures = [initial(left, i) for i in range(len(left.labels))] + signatures += [initial(right, i) for i in range(len(right.labels))] + + def assign(values: list[tuple]) -> list[int]: + table = {value: index for index, value in enumerate(sorted(set(values), key=repr))} + return [table[value] for value in values] + + colors = assign(signatures) + left_colors = colors[: len(left.labels)] + right_colors = colors[len(left.labels) :] + for _ in range(max(len(left.labels), len(right.labels))): + combined = [] + for graph, graph_colors in ((left, left_colors), (right, right_colors)): + combined.extend( + ( + graph_colors[vertex], + tuple( + sorted( + (kind, graph_colors[endpoint]) + for endpoint, kind in graph.adjacency[vertex] + ) + ), + ) + for vertex in range(len(graph.labels)) + ) + refined = assign(combined) + new_left = refined[: len(left.labels)] + new_right = refined[len(left.labels) :] + if new_left == left_colors and new_right == right_colors: + break + left_colors, right_colors = new_left, new_right + return left_colors, right_colors + + +@lru_cache(maxsize=4096) +def species_isomorphic(left: str, right: str) -> bool: + """Return whether two concrete species have the same labeled graph. + + Molecule/site order and explicit bond numbers may differ. Molecule names, + site names/states, wildcard bonds, compartments, and bond connectivity must + match. The implementation uses refinement followed by constrained + backtracking, which keeps the symmetry-heavy ``blbr`` complexes tractable. + """ + + left_graph = _parse_species_graph(left) + right_graph = _parse_species_graph(right) + if left_graph is None or right_graph is None: + return _norm(left) == _norm(right) + if left_graph.header != right_graph.header: + return False + if len(left_graph.labels) != len(right_graph.labels): + return False + + left_colors, right_colors = _refined_colors(left_graph, right_graph) + if Counter(left_colors) != Counter(right_colors): + return False + if Counter( + (left_graph.labels[i], len(left_graph.adjacency[i]), left_colors[i]) + for i in range(len(left_graph.labels)) + ) != Counter( + (right_graph.labels[i], len(right_graph.adjacency[i]), right_colors[i]) + for i in range(len(right_graph.labels)) + ): + return False + + candidates = { + vertex: [ + other + for other in range(len(right_graph.labels)) + if left_colors[vertex] == right_colors[other] + and left_graph.labels[vertex] == right_graph.labels[other] + and len(left_graph.adjacency[vertex]) == len(right_graph.adjacency[other]) + ] + for vertex in range(len(left_graph.labels)) + } + mapping: dict[int, int] = {} + used: set[int] = set() + + def choose_vertex() -> int: + remaining = [i for i in range(len(left_graph.labels)) if i not in mapping] + return min( + remaining, + key=lambda i: ( + -sum(1 for endpoint, _ in left_graph.adjacency[i] if endpoint in mapping), + -len(left_graph.adjacency[i]), + len(candidates[i]), + ), + ) + + def compatible(vertex: int, other: int) -> bool: + for mapped_vertex, mapped_other in mapping.items(): + if _edge_profile(left_graph, vertex, mapped_vertex) != _edge_profile( + right_graph, other, mapped_other + ): + return False + return True + + def search() -> bool: + if len(mapping) == len(left_graph.labels): + return True + vertex = choose_vertex() + for other in candidates[vertex]: + if other in used or not compatible(vertex, other): + continue + mapping[vertex] = other + used.add(other) + if search(): + return True + used.remove(other) + del mapping[vertex] + return False + + return search() + + def normalize_molecule(mol: str) -> str: """Return a form of mol with sites sorted lexicographically. @@ -474,6 +836,7 @@ def parse_net(path: str | Path, *, rate_mode: str = "value") -> Network | None: species: dict[int, str] = {} raw_reactions: list[tuple[list[int], list[int], str]] = [] rate_defs: dict[str, str] = {} + groups: dict[str, dict[int, float]] = {} section = None for line in path.read_text().splitlines(): @@ -497,7 +860,10 @@ def parse_net(path: str | Path, *, rate_mode: str = "value") -> Network | None: idx = int(parts[0]) except ValueError: continue - species[idx] = canonicalize_species(parts[1]) + # Keep the source serialization. Canonical strings are useful for + # display, but they cannot represent graph identity: bond numbers, + # site order, and (critically) site states are independent fields. + species[idx] = parts[1] elif section == "reactions": parts = _norm(stripped).split(" ") @@ -517,12 +883,43 @@ def parse_net(path: str | Path, *, rate_mode: str = "value") -> Network | None: # " " or "=". if "=" in stripped and len(parts) >= 1 and "=" in parts[0]: name, _, expr = stripped.partition("=") - rate_defs[_norm(name)] = _norm(expr) + rate_defs[_rate_definition_key(name)] = _norm(expr) continue if len(parts) >= 3 and parts[0].isdigit(): - rate_defs[parts[1]] = " ".join(parts[2:]) + rate_defs[_rate_definition_key(parts[1])] = " ".join(parts[2:]) elif len(parts) >= 2: - rate_defs[parts[0]] = " ".join(parts[1:]) + rate_defs[_rate_definition_key(parts[0])] = " ".join(parts[1:]) + + elif section == "groups": + parts = _norm(stripped).split(" ", 2) + if len(parts) < 3: + continue + try: + int(parts[0]) + except ValueError: + continue + group_index = int(parts[0]) + name = parts[1] + entries: dict[int, float] = {} + for item in parts[2].split(","): + item = item.strip() + if not item: + continue + if "*" in item: + weight_text, index_text = item.split("*", 1) + try: + weight = float(weight_text) + except ValueError: + continue + else: + weight = 1.0 + index_text = item + try: + index = int(index_text) + except ValueError: + continue + entries[index] = entries.get(index, 0.0) + weight + groups[group_index] = (name, entries) if not species and not raw_reactions: return None @@ -532,6 +929,8 @@ def parse_net(path: str | Path, *, rate_mode: str = "value") -> Network | None: reaction_multiset=Counter(), rate_defs=rate_defs, _raw=raw_reactions, + rate_mode=rate_mode, + groups=groups, ) _rekey(net, rate_mode) return net @@ -563,6 +962,10 @@ class NetDiff: species_only_test: set[str] reactions_only_ref: list reactions_only_test: list + groups_only_ref: set[int] = field(default_factory=set) + groups_only_test: set[int] = field(default_factory=set) + groups_changed: set[int] = field(default_factory=set) + groups_renamed: set[int] = field(default_factory=set) def summary(self) -> str: lines = [ @@ -591,66 +994,201 @@ def summary(self) -> str: lines.append(f" reactions only in ref ({len(self.reactions_only_ref)}):") for r, p, rate in self.reactions_only_ref[:5]: lines.append(f" {' + '.join(r)} -> {' + '.join(p)} [{rate}]") + if self.groups_only_ref: + lines.append( + f" observable groups only in ref ({len(self.groups_only_ref)}): " + + ", ".join(str(i) for i in sorted(self.groups_only_ref)[:5]) + ) + if self.groups_only_test: + lines.append( + f" observable groups only in test ({len(self.groups_only_test)}): " + + ", ".join(str(i) for i in sorted(self.groups_only_test)[:5]) + ) + if self.groups_changed: + lines.append( + f" observable groups changed ({len(self.groups_changed)}): " + + ", ".join(str(i) for i in sorted(self.groups_changed)[:5]) + ) + if self.groups_renamed: + lines.append( + f" observable group labels differ ({len(self.groups_renamed)}): " + + ", ".join(str(i) for i in sorted(self.groups_renamed)[:5]) + ) return "\n".join(lines) def set_rate_mode(net: Network, rate_mode: str) -> Network: """Re-key an already-parsed Network under a different rate_mode, in place.""" + net.rate_mode = rate_mode _rekey(net, rate_mode) return net -def compare_net(ref: Network, test: Network, *, compare_rates: bool = True) -> NetDiff: - """Set-compare two networks by species string and reaction tuple. - - Reactions are keyed by (reactant species strings, product species strings, - rate-key). The rate-key was produced at parse time according to parse_net's - rate_mode (default "value"): auto-generated rate-parameter NAMES are resolved - to their VALUE/expression, so rateLaw4 vs _rateLaw4 vs __R1_local1 do not - cause spurious mismatches, while a genuine difference in rate value/expression - still does. To compare byte-identical names instead, parse with - rate_mode="string". - - compare_rates=False additionally drops the rate-key entirely, comparing pure - topology + stoichiometry. The over-count (blbr/cBNGL) is detectable even in - this mode, since it is a difference in reaction COUNT, not rate. - """ - species_ref = set(ref.species_by_index.values()) - species_test = set(test.species_by_index.values()) - - if compare_rates: - rxn_ref = ref.reaction_multiset - rxn_test = test.reaction_multiset - else: - rxn_ref = Counter( - (r, p) for (r, p, _), n in ref.reaction_multiset.items() for _ in range(n) +def _species_index_mapping(ref: Network, test: Network) -> dict[int, int]: + """Find a one-to-one structural mapping from reference to test species.""" + + reference_indices = list(ref.species_by_index) + test_indices = list(test.species_by_index) + candidates = { + ref_index: [ + test_index + for test_index in test_indices + if species_isomorphic( + ref.species_by_index[ref_index], test.species_by_index[test_index] + ) + ] + for ref_index in reference_indices + } + matched_test: dict[int, int] = {} + + # Kuhn matching handles duplicate/isomorphic species entries without + # depending on their serialized indices or input order. + def augment(ref_index: int, visited: set[int]) -> bool: + for test_index in candidates[ref_index]: + if test_index in visited: + continue + visited.add(test_index) + previous = matched_test.get(test_index) + if previous is None or augment(previous, visited): + matched_test[test_index] = ref_index + return True + return False + + for ref_index in sorted(reference_indices, key=lambda i: len(candidates[i])): + augment(ref_index, set()) + return {ref_index: test_index for test_index, ref_index in matched_test.items()} + + +def _reaction_view( + net: Network, + species_identity: dict[int, tuple[str, int]], + *, + compare_rates: bool, +) -> tuple[Counter, dict[tuple, tuple[tuple[str, ...], tuple[str, ...], str]]]: + """Build a reaction multiset keyed by structural species identities.""" + + counter: Counter = Counter() + payload: dict[tuple, tuple[tuple[str, ...], tuple[str, ...], str]] = {} + for reactants, products, rate in net._raw: + reactant_ids = tuple( + sorted(species_identity.get(index, ("missing", index)) for index in reactants) + ) + product_ids = tuple( + sorted(species_identity.get(index, ("missing", index)) for index in products) ) - rxn_test = Counter( - (r, p) for (r, p, _), n in test.reaction_multiset.items() for _ in range(n) + rate_key = _resolve_rate(rate, net.rate_defs, net.rate_mode) if compare_rates else "" + key = ( + (reactant_ids, product_ids, rate_key) + if compare_rates + else (reactant_ids, product_ids) ) + counter[key] += 1 + try: + reactant_names = tuple(sorted(net.species_by_index[index] for index in reactants)) + product_names = tuple(sorted(net.species_by_index[index] for index in products)) + except KeyError: + reactant_names = tuple(sorted(f"?{index}" for index in reactants)) + product_names = tuple(sorted(f"?{index}" for index in products)) + payload[key] = (reactant_names, product_names, rate_key) + return counter, payload + + +def _group_view( + net: Network, species_identity: dict[int, tuple[str, int]] +) -> dict[int, tuple[str, tuple[tuple[tuple[str, int], float], ...]]]: + """Map indexed observable groups from species indices to identities.""" + + view = {} + for group_index, (name, entries) in net.groups.items(): + mapped: dict[tuple[str, int], float] = {} + for index, weight in entries.items(): + identity = species_identity.get(index, ("missing", index)) + mapped[identity] = mapped.get(identity, 0.0) + weight + view[group_index] = (name, tuple(sorted(mapped.items(), key=repr))) + return view + +def compare_net(ref: Network, test: Network, *, compare_rates: bool = True) -> NetDiff: + """Compare networks by graph identity and reaction multiset. + + Molecule/site order and explicit bond numbers are serialization details. + Molecule names, site states, compartments, explicit connectivity, + stoichiometry, multiplicity, and (when enabled) rate values remain + significant. + """ + mapping = _species_index_mapping(ref, test) + matched_test = set(mapping.values()) + ref_identity = { + index: ("species", mapping[index]) if index in mapping else ("ref", index) + for index in ref.species_by_index + } + test_identity = { + index: ("species", index) if index in matched_test else ("test", index) + for index in test.species_by_index + } + rxn_ref, payload_ref = _reaction_view( + ref, ref_identity, compare_rates=compare_rates + ) + rxn_test, payload_test = _reaction_view( + test, test_identity, compare_rates=compare_rates + ) + groups_ref = _group_view(ref, ref_identity) + groups_test = _group_view(test, test_identity) only_ref = rxn_ref - rxn_test only_test = rxn_test - rxn_ref + groups_only_ref = set(groups_ref) - set(groups_test) + groups_only_test = set(groups_test) - set(groups_ref) + groups_changed = { + index + for index in set(groups_ref) & set(groups_test) + if groups_ref[index][1] != groups_test[index][1] + } + groups_renamed = { + index + for index in set(groups_ref) & set(groups_test) + if groups_ref[index][0] != groups_test[index][0] + } - def _explode(counter): + def _explode(counter, payload): out = [] for key, n in counter.items(): - r, p = key[0], key[1] - rate = key[2] if len(key) > 2 else "" + r, p, rate = payload[key] out.extend([(list(r), list(p), rate)] * n) return out - ok = (species_ref == species_test) and (not only_ref) and (not only_test) + matched_ref = set(mapping) + species_only_ref = { + ref.species_by_index[index] + for index in ref.species_by_index + if index not in matched_ref + } + species_only_test = { + test.species_by_index[index] + for index in test.species_by_index + if index not in matched_test + } + ok = ( + len(mapping) == len(ref.species_by_index) == len(test.species_by_index) + and not only_ref + and not only_test + and not groups_only_ref + and not groups_only_test + and not groups_changed + ) return NetDiff( ok=ok, n_species_ref=ref.n_species, n_species_test=test.n_species, - n_reactions_ref=ref.n_reactions, - n_reactions_test=test.n_reactions, - species_only_ref=species_ref - species_test, - species_only_test=species_test - species_ref, - reactions_only_ref=_explode(only_ref), - reactions_only_test=_explode(only_test), + n_reactions_ref=sum(rxn_ref.values()), + n_reactions_test=sum(rxn_test.values()), + species_only_ref=species_only_ref, + species_only_test=species_only_test, + reactions_only_ref=_explode(only_ref, payload_ref), + reactions_only_test=_explode(only_test, payload_test), + groups_only_ref=groups_only_ref, + groups_only_test=groups_only_test, + groups_changed=groups_changed, + groups_renamed=groups_renamed, ) diff --git a/tests/validation/conftest.py b/tests/validation/conftest.py index a511afcd..1e05a586 100644 --- a/tests/validation/conftest.py +++ b/tests/validation/conftest.py @@ -38,8 +38,10 @@ def _discover_bng_cpp(explicit: str | None) -> Path | None: corpus.REPO / "build" / "cpp" / "bng_cpp", ] for c in candidates: - if c and Path(c).exists(): - return Path(c) + if c: + path = Path(c).expanduser() + if path.exists(): + return path.resolve() return None diff --git a/tests/validation/exceptions.json b/tests/validation/exceptions.json index 394431fe..18a72689 100644 --- a/tests/validation/exceptions.json +++ b/tests/validation/exceptions.json @@ -1,23 +1,4 @@ { "schema_version": 1, - "exceptions": [ - { - "id": "net-parity-blbr-overcount", - "model": "blbr", - "tests": [ - "test_parity_net.py::test_net_parity_smoke[blbr]", - "test_parity_net.py::test_net_parity_full[blbr]" - ], - "method": "network_generation", - "platforms": [ - "all" - ], - "issue_url": "https://github.com/RuleWorld/BNG3/blob/59b774a14aa956a2c25daa6d904e7ad476feda15/BNG3_overcount_analysis.md", - "technical_reason": "BNG3 produces 26 excess network reactions relative to the BNG2 Perl oracle for symmetry-heavy blbr. Failed reaction merging is established; species identity versus origin-rule disambiguation remains unresolved.", - "owner": "BNG3 network-generation maintainers", - "introduced_on": "2026-06-01", - "review_by": "2026-11-26", - "expected_failure_signature": "(?s)^net mismatch \\[blbr\\].*reactions: ref=\\d+ test=\\d+ \\(delta=\\+26\\)" - } - ] + "exceptions": [] } diff --git a/tests/validation/oracle_nfsim.py b/tests/validation/oracle_nfsim.py index ff27a7a8..e84eb541 100644 --- a/tests/validation/oracle_nfsim.py +++ b/tests/validation/oracle_nfsim.py @@ -8,31 +8,33 @@ (model.write_xml) so both sides start from the same model, then run NFsim on it. Configuration (env): - NFSIM_BIN path to the native NFsim executable (default: /build/NFsim) + NFSIM_BIN path to an independently built native NFsim executable + (required; BNG3 build outputs are never selected implicitly) """ from __future__ import annotations +import concurrent.futures import os import subprocess from pathlib import Path from . import corpus -REPO = corpus.REPO - def _nfsim_bin() -> Path | None: configured = os.environ.get("NFSIM_BIN") - candidates = ((Path(configured),) if configured else ()) + ( - REPO / "build" / "NFsim", - REPO / "build" / "NFsim.exe", - REPO / "build" / "cpp" / "NFsim", - ) - for cand in candidates: - if cand.exists(): - return cand - return None + if not configured: + # BNG3's optional NFsim target is built from the embedded convergence + # sources and is not an independent oracle. Require callers to name + # a binary built from the pinned pre-convergence NFsim source. + return None + + # An explicit oracle path is an assertion about the binary to use. Never + # silently replace a missing native oracle with BNG3's own CLI; that would + # turn an independent parity check into a self-comparison. + path = Path(configured).expanduser() + return path.resolve() if path.exists() else None def nfsim_available() -> bool: @@ -91,6 +93,29 @@ def run_nfsim( return (gdat, proc.stderr) if gdat.exists() else (None, "no .gdat produced") +def _resolve_ensemble_workers(requested: int | None, n_runs: int) -> int: + """Resolve bounded native-oracle concurrency from the shared env knob.""" + if n_runs < 0: + raise ValueError("n_runs must be non-negative") + if requested is None: + raw = os.environ.get("BNG_ENSEMBLE_WORKERS") + requested = int(raw) if raw else 4 + if requested < 1: + raise ValueError("ensemble workers must be at least one") + return min(requested, n_runs) if n_runs else 1 + + +def _run_nfsim_ensemble_item(payload): + xml_path, work_dir, t_end, n_steps, seed = payload + return run_nfsim( + xml_path, + work_dir, + t_end=t_end, + n_steps=n_steps, + seed=seed, + ) + + def ensemble( model_name: str, work_dir: Path, @@ -99,18 +124,29 @@ def ensemble( base_seed: int = 1, t_end: float = 100.0, n_steps: int = 100, + workers: int | None = None, ): - """Native-NFsim ensemble for the model, in compare.compare_stochastic shape.""" + """Native-NFsim ensemble in compare.compare_stochastic shape.""" from .compare import parse_gdat xml = write_model_xml(model_name, work_dir / f"{Path(model_name).stem}.xml") if xml is None: return [] + worker_count = _resolve_ensemble_workers(workers, n_runs) + payloads = [ + (xml, work_dir / f"run{i}", t_end, n_steps, base_seed + i) + for i in range(n_runs) + ] + if worker_count == 1: + results = [_run_nfsim_ensemble_item(payload) for payload in payloads] + else: + with concurrent.futures.ThreadPoolExecutor( + max_workers=worker_count + ) as pool: + results = list(pool.map(_run_nfsim_ensemble_item, payloads)) + runs = [] - for i in range(n_runs): - gdat, _ = run_nfsim( - xml, work_dir / f"run{i}", t_end=t_end, n_steps=n_steps, seed=base_seed + i - ) + for gdat, _ in results: if gdat is None: continue data, cols = parse_gdat(gdat) diff --git a/tests/validation/reference_exclusions.json b/tests/validation/reference_exclusions.json new file mode 100644 index 00000000..dbdc704c --- /dev/null +++ b/tests/validation/reference_exclusions.json @@ -0,0 +1,111 @@ +{ + "schema_version": 1, + "status": "pending-maintainer-approval", + "audit_date": "2026-09-02", + "audit_note": "Profiles contain only the current reference-validation gaps; remove an entry when its independent reference or native path is repaired.", + "profiles": { + "pull_request": [ + "ANx", + "egfr_net", + "fceri_ji", + "gene_expr", + "gene_expr_func", + "gene_expr_simple", + "hybrid_test", + "isomerization", + "nfkb_illustrating_protocols", + "test_MM", + "test_expression_xml_escaping", + "test_paramname", + "test_partial_dynamical_scaling", + "test_sat", + "test_sat_cont", + "test_tfun", + "test_tfun_expr", + "test_tfun_expr2", + "test_tfun_file", + "test_tfun_net_full", + "test_tfun_observable", + "test_tfun_ode_simple", + "test_tfun_ode_step", + "test_tfun_parse", + "test_tfun_step_sbml", + "test_tfun_validation", + "test_tfun_xml", + "test_write_sbml_multi", + "tlmr", + "visualize", + "test_sbml_structured" + ], + "weekly": [ + "ANx", + "egfr_net", + "fceri_ji", + "gene_expr", + "gene_expr_func", + "gene_expr_simple", + "hybrid_test", + "isomerization", + "nfkb_illustrating_protocols", + "test_MM", + "test_expression_xml_escaping", + "test_paramname", + "test_partial_dynamical_scaling", + "test_sat", + "test_sat_cont", + "test_tfun", + "test_tfun_expr", + "test_tfun_expr2", + "test_tfun_file", + "test_tfun_net_full", + "test_tfun_observable", + "test_tfun_ode_simple", + "test_tfun_ode_step", + "test_tfun_parse", + "test_tfun_step_sbml", + "test_tfun_validation", + "test_tfun_xml", + "test_write_sbml_multi", + "tlmr", + "visualize", + "test_sbml_structured" + ] + }, + "reasons": { + "missing_reference_net": [ + "ANx", + "egfr_net", + "fceri_ji", + "gene_expr", + "gene_expr_func", + "gene_expr_simple", + "hybrid_test", + "isomerization", + "nfkb_illustrating_protocols", + "test_MM", + "test_expression_xml_escaping", + "test_paramname", + "test_partial_dynamical_scaling", + "test_sat", + "test_sat_cont", + "test_tfun", + "test_tfun_expr", + "test_tfun_expr2", + "test_tfun_file", + "test_tfun_net_full", + "test_tfun_observable", + "test_tfun_ode_simple", + "test_tfun_ode_step", + "test_tfun_parse", + "test_tfun_step_sbml", + "test_tfun_validation", + "test_tfun_xml", + "test_write_sbml_multi", + "tlmr", + "visualize" + ], + "unsupported_native_path": [ + "test_sbml_structured" + ] + } +} diff --git a/tests/validation/runner.py b/tests/validation/runner.py index efcc47d1..850db388 100644 --- a/tests/validation/runner.py +++ b/tests/validation/runner.py @@ -13,9 +13,12 @@ from __future__ import annotations +import concurrent.futures +import multiprocessing import os import shutil import subprocess +import sys from dataclasses import dataclass from pathlib import Path @@ -45,6 +48,17 @@ def run_cli(bng_cpp: Path, model_name: str, work_dir: Path, *, timeout: int = 18 if src is None: return None, None, f"model {model_name!r} not found on disk" + return run_cli_path(bng_cpp, src, work_dir, timeout=timeout) + + +def run_cli_path( + bng_cpp: Path, source_path: Path, work_dir: Path, *, timeout: int = 180 +): + """Run bng_cpp on an explicit BNGL source path in an isolated directory.""" + src = Path(source_path) + if not src.is_file(): + return None, None, f"model source {str(src)!r} not found on disk" + work_dir.mkdir(parents=True, exist_ok=True) local = work_dir / src.name shutil.copy2(src, local) @@ -110,18 +124,57 @@ def run_api( return _result_to_trajectory(result) +def _run_api_ensemble_item(payload: tuple[str, str, int, dict]) -> Trajectory: + """Run one ensemble member in a clean worker process.""" + model_name, method, seed, kwargs = payload + return run_api(model_name, method=method, seed=seed, **kwargs) + + +def _resolve_ensemble_workers(requested: int | None, n_runs: int) -> int: + """Resolve a bounded worker count without changing fixed-seed semantics.""" + if n_runs < 0: + raise ValueError("n_runs must be non-negative") + if requested is None: + raw = os.environ.get("BNG_ENSEMBLE_WORKERS") + requested = int(raw) if raw else 4 + if requested < 1: + raise ValueError("ensemble workers must be at least one") + return min(requested, n_runs) if n_runs else 1 + + def run_api_ensemble( model_name: str, *, method: str = "ssa", n_runs: int = 200, base_seed: int = 1, + workers: int | None = None, **kwargs, ) -> list[tuple[np.ndarray, list[str]]]: - """Run a seeded stochastic ensemble; returns runs in compare.py's shape.""" + """Run a seeded stochastic ensemble; returns runs in compare.py's shape. + + Members use explicit seeds and are returned in seed order. The default is + four isolated worker processes; set ``BNG_ENSEMBLE_WORKERS=1`` or pass + ``workers=1`` for serial debugging. + """ + worker_count = _resolve_ensemble_workers(workers, n_runs) + payloads = [ + (model_name, method, base_seed + i, kwargs) for i in range(n_runs) + ] + if worker_count == 1: + trajectories = [_run_api_ensemble_item(payload) for payload in payloads] + else: + repo_path = str(corpus.REPO) + if repo_path not in sys.path: + sys.path.insert(0, repo_path) + context = multiprocessing.get_context("spawn") + with concurrent.futures.ProcessPoolExecutor( + max_workers=worker_count, mp_context=context + ) as pool: + trajectories = list(pool.map(_run_api_ensemble_item, payloads)) + runs = [] - for i in range(n_runs): - traj = run_api(model_name, method=method, seed=base_seed + i, **kwargs) + for traj in trajectories: runs.append((traj.data, traj.columns)) return runs diff --git a/tests/validation/test_compare_net.py b/tests/validation/test_compare_net.py index 7f463542..69f6cfbe 100644 --- a/tests/validation/test_compare_net.py +++ b/tests/validation/test_compare_net.py @@ -2,9 +2,10 @@ from __future__ import annotations +import math from pathlib import Path -from tests.validation.compare import compare_net, parse_net +from tests.validation.compare import compare_net, parse_net, species_isomorphic def _net(path: Path, rate: str) -> Path: @@ -37,6 +38,80 @@ def test_equivalent_built_in_rate_expressions_compare_equal(tmp_path): assert compare_net(reference, generated).ok +def test_net_rate_comparison_preserves_natural_log_semantics(tmp_path): + reference = parse_net(_net(tmp_path / "reference.net", "ln(2)/120")) + generated = parse_net( + _net(tmp_path / "generated.net", repr(math.log(2) / 120)) + ) + assert reference is not None + assert generated is not None + assert compare_net(reference, generated).ok + + +def test_function_definitions_strip_call_suffix_for_rate_resolution(tmp_path): + reference = tmp_path / "reference.net" + reference.write_text( + "\n".join( + [ + "begin parameters", + " 1 kd 0.5", + "end parameters", + "begin functions", + " 1 f() 2*kd", + "end functions", + "begin species", + " 1 A() 1", + " 2 B() 0", + "end species", + "begin reactions", + " 1 1 2 f", + "end reactions", + ] + ) + + "\n", + encoding="utf-8", + ) + generated = _net(tmp_path / "generated.net", "1.0") + parsed_reference = parse_net(reference) + parsed_generated = parse_net(generated) + assert parsed_reference is not None + assert parsed_generated is not None + assert compare_net(parsed_reference, parsed_generated).ok + + +def test_function_rate_normalizes_negative_product_parentheses(tmp_path): + def write(path: Path, expression: str) -> Path: + path.write_text( + "\n".join( + [ + "begin species", + " 1 A() 1", + " 2 B() 0", + "end species", + "begin functions", + f" 1 f() {expression}", + "end functions", + "begin reactions", + " 1 1 2 f", + "end reactions", + ] + ) + + "\n", + encoding="utf-8", + ) + return path + + reference = parse_net( + write(tmp_path / "reference.net", "-((2/3)*X)") + ) + generated = parse_net( + write(tmp_path / "generated.net", "-(2/3)*X") + ) + assert reference is not None + assert generated is not None + assert compare_net(reference, generated).ok + + def test_same_counts_with_different_rate_values_fail(tmp_path): reference = parse_net(_net(tmp_path / "reference.net", "0.5")) generated = parse_net(_net(tmp_path / "generated.net", "1.0")) @@ -46,3 +121,129 @@ def test_same_counts_with_different_rate_values_fail(tmp_path): assert not diff.ok assert diff.n_reactions_ref == diff.n_reactions_test == 1 assert diff.reactions_only_test + + +def test_species_graph_equivalence_ignores_order_and_bond_labels(): + reference = "A(x!10,y).B(z!10)" + generated = "B(z!1).A(y,x!1)" + assert species_isomorphic(reference, generated) + + +def test_species_graph_equivalence_preserves_states_and_connectivity(): + assert not species_isomorphic("A(x~p!1).B(y!1)", "A(x~u!1).B(y!1)") + assert not species_isomorphic( + "A(x!1,y!2).B(x!1,y!3).C(x!2,y!3)", + "A(x!1,y!2).B(x!1,y!3).C(x!2,y)", + ) + + +def test_blbr_symmetric_serializations_are_graph_equivalent(): + reference = ( + "L(l!1,l!2).R(r!1,r!3).R(r!2,r!4).L(l!3,l!5)." + "L(l!4,l!6).R(r!5,r!7).R(r!6,r!8).L(l!7,l!9)." + "L(l,l!8).R(r!9)" + ) + generated = ( + "L(l!1,l!2).R(r!1,r!3).R(r!2,r!4).L(l!3,l!5)." + "L(l!4,l!6).R(r!5,r!7).R(r!6,r!8).L(l,l!7)." + "L(l!8,l!9).R(r!9)" + ) + assert species_isomorphic(reference, generated) + + +def test_network_comparison_uses_graph_identity_for_reactions(tmp_path): + def write(path: Path, first: str, second: str, reaction: str): + path.write_text( + "\n".join( + [ + "begin parameters", + " 1 kd 0.5 # Constant", + "end parameters", + "begin species", + f" 1 {first} 1", + f" 2 {second} 0", + "end species", + "begin reactions", + f" 1 {reaction} kd", + "end reactions", + ] + ) + + "\n", + encoding="utf-8", + ) + return path + + reference = parse_net( + write( + tmp_path / "reference.net", + "A(x!1).B(y!1)", + "C()", + "1 2", + ) + ) + generated = parse_net( + write( + tmp_path / "generated.net", + "C()", + "B(y!99).A(x!99)", + "2 1", + ) + ) + assert reference is not None + assert generated is not None + assert compare_net(reference, generated).ok + + +def _net_with_group( + path: Path, first: str, second: str, group: str, reaction: str = "1 2" +) -> Path: + path.write_text( + "\n".join( + [ + "begin parameters", + " 1 kd 0.5 # Constant", + "end parameters", + "begin species", + f" 1 {first} 1", + f" 2 {second} 0", + "end species", + "begin reactions", + f" 1 {reaction} kd", + "end reactions", + "begin groups", + f" 1 Atot {group}", + "end groups", + ] + ) + + "\n", + encoding="utf-8", + ) + return path + + +def test_network_comparison_maps_observable_groups_with_species(tmp_path): + reference = parse_net( + _net_with_group( + tmp_path / "reference.net", "A(x!1).B(y!1)", "C()", "1,2*2" + ) + ) + generated = parse_net( + _net_with_group( + tmp_path / "generated.net", "C()", "B(y!9).A(x!9)", "2*1,2", "2 1" + ) + ) + assert reference is not None + assert generated is not None + assert compare_net(reference, generated).ok + + +def test_network_comparison_rejects_changed_observable_group_weight(tmp_path): + reference = parse_net( + _net_with_group(tmp_path / "reference.net", "A()", "B()", "1,2*2") + ) + generated = parse_net( + _net_with_group(tmp_path / "generated.net", "A()", "B()", "1,3*2") + ) + assert reference is not None + assert generated is not None + assert not compare_net(reference, generated).ok diff --git a/tests/validation/test_exception_ledger.py b/tests/validation/test_exception_ledger.py index c1d97ce0..22501200 100644 --- a/tests/validation/test_exception_ledger.py +++ b/tests/validation/test_exception_ledger.py @@ -10,6 +10,41 @@ from tests.validation import exception_ledger +SAMPLE_EXCEPTION = { + "id": "sample-exception", + "model": "blbr", + "tests": ["test_parity_net.py::test_net_parity_smoke[blbr]"], + "method": "network_generation", + "platforms": ["all"], + "issue_url": "https://example.com/sample-exception", + "technical_reason": "test fixture", + "owner": "test fixture", + "introduced_on": "2026-06-01", + "review_by": "2026-11-26", + "expected_failure_signature": "^expected failure$", +} + + +def _sample_document(): + return {"schema_version": 1, "exceptions": [dict(SAMPLE_EXCEPTION)]} + + +def _sample_entry(): + return exception_ledger.ExpectedFailure( + id="sample-exception", + model="blbr", + tests=("test_parity_net.py::test_net_parity_smoke[blbr]",), + method="network_generation", + platforms=("all",), + issue_url="https://example.com/sample-exception", + technical_reason="test fixture", + owner="test fixture", + introduced_on=date(2026, 6, 1), + review_by=date(2026, 11, 26), + expected_failure_signature="^expected failure$", + ) + + def _write_ledger(tmp_path, document): path = tmp_path / "exceptions.json" path.write_text(json.dumps(document), encoding="utf-8") @@ -20,24 +55,16 @@ def test_repository_exception_ledger_is_valid(): ledger = exception_ledger.load_ledger(as_of=date(2026, 8, 28)) exception_ledger.validate_references(ledger) - assert len(ledger.exceptions) == 1 - entry = ledger.find( + assert len(ledger.exceptions) == 0 + assert ledger.find( "test_parity_net.py::test_net_parity_smoke[blbr]", "blbr", "network_generation", - ) - assert entry is not None - assert entry.matches_failure( - "net mismatch [blbr] (ref=golden):\n" "reactions: ref=66 test=92 (delta=+26)" - ) - assert not entry.matches_failure( - "net mismatch [blbr] (ref=golden):\n" "reactions: ref=67 test=92 (delta=+25)" - ) + ) is None def test_strict_exception_rejects_unexpected_pass(): - ledger = exception_ledger.load_ledger(as_of=date(2026, 8, 28)) - entry = ledger.exceptions[0] + entry = _sample_entry() with pytest.raises(pytest.fail.Exception, match="unexpected pass"): with exception_ledger.strict_expected_failure(entry): @@ -45,8 +72,7 @@ def test_strict_exception_rejects_unexpected_pass(): def test_strict_exception_does_not_hide_a_different_failure(): - ledger = exception_ledger.load_ledger(as_of=date(2026, 8, 28)) - entry = ledger.exceptions[0] + entry = _sample_entry() with pytest.raises(AssertionError, match="engine produced no .net"): with exception_ledger.strict_expected_failure(entry): @@ -54,7 +80,7 @@ def test_strict_exception_does_not_hide_a_different_failure(): def test_ledger_rejects_missing_required_field(tmp_path): - document = json.loads(exception_ledger.LEDGER_PATH.read_text(encoding="utf-8")) + document = _sample_document() del document["exceptions"][0]["owner"] with pytest.raises(exception_ledger.LedgerError, match="owner"): @@ -64,7 +90,7 @@ def test_ledger_rejects_missing_required_field(tmp_path): def test_ledger_rejects_overdue_review(tmp_path): - document = json.loads(exception_ledger.LEDGER_PATH.read_text(encoding="utf-8")) + document = _sample_document() with pytest.raises(exception_ledger.LedgerError, match="review overdue"): exception_ledger.load_ledger( @@ -73,7 +99,7 @@ def test_ledger_rejects_overdue_review(tmp_path): def test_ledger_rejects_duplicate_assignment(tmp_path): - document = json.loads(exception_ledger.LEDGER_PATH.read_text(encoding="utf-8")) + document = _sample_document() duplicate = dict(document["exceptions"][0]) duplicate["id"] = "duplicate-id" document["exceptions"].append(duplicate) diff --git a/tests/validation/test_export_formats.py b/tests/validation/test_export_formats.py index 1f418160..873f4188 100644 --- a/tests/validation/test_export_formats.py +++ b/tests/validation/test_export_formats.py @@ -32,8 +32,18 @@ def test_net_roundtrip_idempotent(model_name, bng_cpp, work_dir): """write -> read -> write produces an identical network.""" net1, _, err = runner.run_cli(bng_cpp, model_name, work_dir / "pass1") assert net1 is not None, f"first generation failed: {err}" - # Second pass over the same model must yield an identical network. - net2, _, err2 = runner.run_cli(bng_cpp, model_name, work_dir / "pass2") + + # Follow BNG2's michment/michment_cont contract: the second pass must read + # the first pass's .net file, not regenerate the BNGL model. + roundtrip_source = work_dir / f"{model_name}_roundtrip.bngl" + roundtrip_source.write_text( + f'readFile({{file=>"{net1.as_posix()}"}})\n' + "writeNetwork({overwrite=>1})\n", + encoding="utf-8", + ) + net2, _, err2 = runner.run_cli_path( + bng_cpp, roundtrip_source, work_dir / "pass2" + ) assert net2 is not None, f"second generation failed: {err2}" n1, n2 = compare.parse_net(net1), compare.parse_net(net2) diff = compare.compare_net(n1, n2) diff --git a/tests/validation/test_harness_paths.py b/tests/validation/test_harness_paths.py new file mode 100644 index 00000000..e5fde8d1 --- /dev/null +++ b/tests/validation/test_harness_paths.py @@ -0,0 +1,76 @@ +"""Validation-harness path contracts. + +The runners change their subprocess working directory, so configured binary +paths must be anchored before execution begins. +""" + +from __future__ import annotations + +import pytest + +from tests.validation import conftest, oracle_nfsim, runner + + +def test_configured_nfsim_path_is_anchored_to_discovery_directory(tmp_path, monkeypatch): + binary = tmp_path / "bin" / "NFsim" + binary.parent.mkdir() + binary.touch() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("NFSIM_BIN", "bin/NFsim") + + assert oracle_nfsim._nfsim_bin() == binary.resolve() + + +def test_missing_configured_nfsim_path_does_not_fall_back(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("NFSIM_BIN", "missing/NFsim") + + assert oracle_nfsim._nfsim_bin() is None + + +def test_unconfigured_nfsim_requires_an_explicit_oracle(monkeypatch): + monkeypatch.delenv("NFSIM_BIN", raising=False) + + assert oracle_nfsim._nfsim_bin() is None + + +def test_explicit_bng_cpp_path_is_anchored_to_discovery_directory(tmp_path, monkeypatch): + binary = tmp_path / "bin" / "bng_cpp" + binary.parent.mkdir() + binary.touch() + monkeypatch.chdir(tmp_path) + + assert conftest._discover_bng_cpp("bin/bng_cpp") == binary.resolve() + + +def test_api_ensemble_parallel_workers_preserve_seed_order(): + runs = runner.run_api_ensemble( + "simple_system", + method="nf", + n_runs=2, + base_seed=11, + t_end=1.0, + n_steps=1, + workers=2, + ) + + assert len(runs) == 2 + assert [columns[0] for _, columns in runs] == ["time", "time"] + + +def test_native_ensemble_parallel_workers_preserve_seed_order(tmp_path): + if not oracle_nfsim.nfsim_available(): + pytest.skip("native NFsim binary not available") + + runs = oracle_nfsim.ensemble( + "simple_system", + tmp_path, + n_runs=2, + base_seed=11, + t_end=1.0, + n_steps=1, + workers=2, + ) + + assert len(runs) == 2 + assert runs[0][0][0, 0] == pytest.approx(0.0) diff --git a/tests/validation/test_parity_nfsim.py b/tests/validation/test_parity_nfsim.py index 1d9c68fd..c58a496d 100644 --- a/tests/validation/test_parity_nfsim.py +++ b/tests/validation/test_parity_nfsim.py @@ -13,6 +13,7 @@ import os +import numpy as np import pytest from tests.validation import compare, corpus, oracle_nfsim, runner @@ -53,6 +54,7 @@ def test_nf_ast_direct_matches_xml(model_name, api, work_dir, monkeypatch): t_end, n_steps, seed = 50.0, 50, 7 monkeypatch.setenv("BNG_NFSIM_FORCE_XML", "1") + monkeypatch.setenv("BNG_NFSIM_ALLOW_XML_FALLBACK", "1") xml_traj = runner.run_api(model_name, method="nf", seed=seed, t_end=t_end, n_steps=n_steps) monkeypatch.delenv("BNG_NFSIM_FORCE_XML", raising=False) @@ -65,3 +67,46 @@ def test_nf_ast_direct_matches_xml(model_name, api, work_dir, monkeypatch): assert diff.ok or diff.max_rel_err == 0.0, ( f"ast-direct diverges from in-memory-XML [{model_name}]: {diff.summary()}" ) + + +@pytest.mark.nf +@pytest.mark.slow +@pytest.mark.parametrize("model_name,t_end", [("motor", 0.2), ("tlbr", 2.0)]) +def test_nf_fixed_seed_direct_matches_native_at_final_endpoint( + model_name, t_end, api, work_dir +): + """The direct API must retain native NFsim's final endpoint event semantics.""" + if not oracle_nfsim.nfsim_available(): + pytest.skip("native NFsim binary not found (set NFSIM_BIN)") + + # Source-derived from nfsim/test/motor and nfsim/test/tlbr. Native + # System::sim includes an event whose waiting time crosses the final output + # boundary; this is intentionally a fixed-seed contract, not a tolerance + # or ensemble substitute. + xml_path = oracle_nfsim.write_model_xml( + model_name, work_dir / "native-endpoint" / f"{model_name}.xml" + ) + assert xml_path is not None + native_path, stderr = oracle_nfsim.run_nfsim( + xml_path, + work_dir / "native-endpoint" / "run", + t_end=t_end, + n_steps=20, + seed=1, + ) + assert native_path is not None, stderr + native_data, native_columns = compare.parse_gdat(native_path) + assert native_data is not None + + direct = runner.run_api( + model_name, + method="nf", + t_end=t_end, + n_steps=20, + seed=1, + ) + + assert direct.columns == native_columns + assert direct.data.shape == native_data.shape + assert np.allclose(direct.data[:, 0], native_data[:, 0], rtol=0.0, atol=1e-12) + assert np.array_equal(direct.data[:, 1:], native_data[:, 1:]) diff --git a/tests/validation/test_parity_ode.py b/tests/validation/test_parity_ode.py index 6a9af2dc..44060781 100644 --- a/tests/validation/test_parity_ode.py +++ b/tests/validation/test_parity_ode.py @@ -1,4 +1,4 @@ -"""ODE trajectory parity: API simulate('ode') vs Perl/golden .gdat, rtol 1e-6.""" +"""ODE trajectory parity: action-aware BNG3 CLI vs Perl/golden ``.gdat``.""" from __future__ import annotations @@ -20,7 +20,7 @@ @pytest.mark.smoke @pytest.mark.parametrize("model_name", ODE_MODELS) -def test_ode_parity(model_name, api, work_dir): +def test_ode_parity(model_name, bng_cpp, work_dir): ref_path, ref_src = oracle_perl.gdat(model_name, work_dir / "perl") if ref_path is None: pytest.skip(f"no reference .gdat for {model_name}: {ref_src}") @@ -28,12 +28,16 @@ def test_ode_parity(model_name, api, work_dir): ref_data, ref_cols = compare.parse_gdat(ref_path) assert ref_data is not None, f"could not parse reference .gdat ({ref_src})" - # Match the reference time span/steps so grids align. - t_end = float(ref_data[-1, ref_cols.index("time")] if "time" in ref_cols else ref_data[-1, 0]) - n_steps = ref_data.shape[0] - 1 - traj = runner.run_api(model_name, method="ode", t_end=t_end, n_steps=n_steps) + # The BNG2 reference is produced by executing the model's action block. + # Use the action-aware CLI path so setup actions (for example + # setConcentration, sparse CVODE, and multi-phase continuation) are not + # silently discarded by the direct API path. + _, test_path, err = runner.run_cli(bng_cpp, model_name, work_dir / "cpp") + assert test_path is not None, f"engine produced no trajectory: {err}" + test_data, test_cols = compare.parse_gdat(test_path) + assert test_data is not None, "could not parse engine .gdat" diff = compare.compare_trajectories( - ref_data, ref_cols, traj.data, traj.columns, rtol=1e-6 + ref_data, ref_cols, test_data, test_cols, rtol=1e-6 ) assert diff.ok, f"ODE mismatch [{model_name}] (ref={ref_src}): {diff.summary()}" diff --git a/tests/validation/test_reference_fixtures.py b/tests/validation/test_reference_fixtures.py new file mode 100644 index 00000000..8cb8e182 --- /dev/null +++ b/tests/validation/test_reference_fixtures.py @@ -0,0 +1,22 @@ +"""Tests for duplicated validation fixtures and their source provenance.""" + +from __future__ import annotations + +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[2] + + +def test_canonical_model_fixture_matches_validation_fixture(): + """The validated cBNGL model must not drift from the canonical fixture.""" + + canonical = REPO / "models" / "Motivating_example_cBNGL.bngl" + validation = ( + REPO + / "tests" + / "validation" + / "Validate" + / "Motivating_example_cBNGL.bngl" + ) + assert validation.read_bytes() == canonical.read_bytes()