Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,6 @@ jobs:
run: |
python -c "import strider._native; print(strider._native.melting_temperature('ATGCGTACGATCGATCGATC'))"

- name: Run native parity suite (Rust vs Python, 10k-sequence fuzz)
- name: Run native parity suites (Rust vs Python)
run: |
pytest tests/test_native_parity.py -v
pytest tests/test_native_parity.py tests/test_native_dp_parity.py -v
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,6 @@ paper/

# Rust build artifacts (native accelerator)
native/target/

# Local benchmark evidence (engine parity runs, etc.)
evidence/
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,63 @@ This file is generated from the git history by [git-cliff](https://git-cliff.org
The format follows [Keep a Changelog](https://keepachangelog.com) and the project
uses [Semantic Versioning](https://semver.org).

## [Unreleased]

### Behavior Changes

- **thermo:** `fraction_folded` returns 0.0 for non-folding sequences and no
longer swallows caller mistakes

The blanket `except ValueError` collapsed every failure (bad
``salt_model`` choice, degenerate entropy, multiloop MFE) into a silent
``0.0``. The zero case is now decided from the MFE structure itself:
only a sequence with no base pairs in its MFE maps to ``0.0``; everything
else still raises. This is a behavior change for
``fraction_folded`` under invalid arguments.

- **thermo:** `hairpin_thermo` accepts custom parameter sets, and the dimer
path gains a ``dangles`` flag

``hairpin_thermo(paramset=...)`` now folds with the same parameter set it
scores with (previously the native set picked the structure while the
custom set scored it) and both ``hairpin_thermo`` and
``fraction_folded`` accept a paramset name as well as an instance.
``dimer_thermo`` / ``dimer_thermo_subopt`` / ``dimer_tm`` take
``dangles=0|2`` matching the hairpin flag: each duplex terminus gathers
the negative dangling-end stacks adjacent to its outermost pair.
Note the changing default for ``_sum_dimer_elements``: exterior flank
contributions are now gated by the flag (``0`` = off), where they were
previously always included, so dimer ΔG/Tm for structures with dangling
flanks shift vs earlier releases.
Separate latent fix: enthalpy walks no longer ingest ΔG-valued dangle
tables (the dangle parameter tables are ΔG-only; the ΔH path now always
excludes them).

- **thermo:** ``dangles`` is scoped to MFE/suboptimal results

``ThermoEngine(dangles=...)`` no longer feeds the partition-function path:
the ensemble, ensemble-defect, differentiable and concentration-solving
code do not implement dangle semantics, and carrying the flag into the
pfunc cache key implied an effect that does not exist. ``0`` vs ``2``
therefore changes ``mfe``/``subopt`` results only; ``pfunc`` is
bit-identical across flag states.

- **structure:** exterior dangle rule no longer drops a valid strand-start
flank in ``fold_complex``/``fold_mfe``

The 5' branch of the exterior dangle walk tested
``(ko - 1) not in nicks`` in addition to the required contiguity check
(``ko not in nicks``), discarding a legitimate 5' dangle whenever the
flanking base was the first base of a strand.

### Documentation

- **notices:** value-level confirmation for the `dna_mathews2004.par`
provenance claim (the 1999 and 2004 Vienna DNA sets differ: `ATAT` is
-0.8/-0.9 and `GT/CG` is +1.3/+1.2 respectively; the JSON carries the
2004-file markers). Citation: Mathews 1999 JMB 288:911-940 lineage plus
NNDB Turner & Mathews 2010.

## [1.2.1] - 2026-07-28

### Bug Fixes
Expand Down
23 changes: 23 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,29 @@ These are consumed by scripts under `scripts/` to *generate* the table modules
above. They are not shipped: the `data/` directory is `.gitignore`d in its
entirety and excluded from the wheel.

- **ViennaRNA `dna_mathews2004.par`** — used by
`scripts/generate_mathews2004_params.py` to transcribe the DNA ΔG₃₇ and ΔH
tables into `strider/thermo/parameters/mathews2004-dna.json`. **Identity of
the transcribed file was re-confirmed at value level in this review round:**
ViennaRNA ships both a 1999 and a 2004 DNA set whose stack matrices differ
(e.g. `ATAT` = −0.8 in the 1999 file vs −0.9 in the 2004 file; `GT/CG` = +1.3
vs +1.2). The JSON here carries ATAT = −0.9 and GTGT = +1.2, matching
`dna_mathews2004.par` exactly and excluding the 1999 set. The JSON is
regenerated with ViennaRNA installed at development time (`pip install
ViennaRNA`) and verified structure-by-structure against ViennaRNA's own
`eval_structure` at `dangles=0`. Primary literature for the set is Mathews
D.H., Sabina J., Zuker M., Turner D.H. (1999) *J. Mol. Biol.* **288**:911-940;
the refined values distributed in the 2004 `.par` track that lineage plus
the Turner group corrections ultimately consolidated in the NNDB (Turner &
Mathews 2010, Nucleic Acids Res. **38**:D280-D282, cited above). These are
physical measurements, not copyrightable expression, so the JSON retains only
those numeric constants (as does the analogous adoption of the same parameter
set in e.g. NUPACK, RNAstructure, and Biopython). Neither the `.par` file nor
ViennaRNA (© Institute for Theoretical Chemistry, University of Vienna) is
redistributed or required at runtime by strider;
`strider/thermo/parameters/mathews2004-dna.json`
is self-contained static data under the package's MIT license.

- **ViennaRNA `rna_turner2004.par`** — used by
`scripts/generate_rna_enthalpy_tables.py` to transcribe Turner-2004 RNA loop
enthalpies. Fetch on demand:
Expand Down
104 changes: 104 additions & 0 deletions native/codegen_tables.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Generate ``native/src/tables_dna.rs`` from the Python source of truth.

``strider.thermo.parameters_dna`` holds the DNA nearest-neighbor energy tables
(ΔG at 37 °C, kcal/mol) used by the folding DP. Hand-copying them into Rust
(INTERIOR_2_2 alone: 9216 entries) would silently drift; instead this script
packs each string key into a 2-bit-per-base code and emits a sorted array per
table so the Rust side can binary-search at lookup time.

The generated file is committed to the repository (like the package's own
``strider/thermo/_dna_enthalpy_generated.py``); a pytest staleness check
(``test_tables_dna_regenerated_matches_committed`` in
``tests/test_native_dp_parity.py``) regenerates and compares byte-for-byte.
Regenerate with::

python native/codegen_tables.py

Values are emitted via Python ``repr`` floats, which round-trip exactly, so
the Rust constants are bit-identical to the Python dictionary values.
"""

from __future__ import annotations

import sys

from strider.thermo import parameters_dna as P

BASE = {"A": 0, "C": 1, "G": 2, "T": 3, "U": 3}

# (rust_const_name, python_attr, kind) — kind: "dict" | "vec"
TABLES = [
("STACK", "STACK", "dict"),
("TERMINAL_PENALTY", "TERMINAL_PENALTY", "dict"),
("INTERIOR_MISMATCH", "INTERIOR_MISMATCH", "dict"),
("INTERIOR_1_1", "INTERIOR_1_1", "dict"),
("INTERIOR_1_2", "INTERIOR_1_2", "dict"),
("INTERIOR_2_2", "INTERIOR_2_2", "dict"),
("DANGLE_3", "DANGLE_3", "dict"),
("DANGLE_5", "DANGLE_5", "dict"),
("BULGE_SIZE", "BULGE_SIZE", "vec"),
("INTERIOR_SIZE", "INTERIOR_SIZE", "vec"),
("HAIRPIN_SIZE", "HAIRPIN_SIZE", "vec"),
("HAIRPIN_MISMATCH", "HAIRPIN_MISMATCH", "dict"),
("HAIRPIN_TRILOOP", "HAIRPIN_TRILOOP", "dict"),
("HAIRPIN_TETRALOOP", "HAIRPIN_TETRALOOP", "dict"),
("ASYMMETRY_NINIO", "ASYMMETRY_NINIO", "vec"),
("LOG_LOOP_PENALTY", "LOG_LOOP_PENALTY", "scalar"),
]

HEADER = """\
// GENERATED by native/codegen_tables.py from strider.thermo.parameters_dna —
// DO NOT EDIT BY HAND. Run `python native/codegen_tables.py` to regenerate.
//
// Each `dict` table is a sorted `&[(u32, f64)]` keyed by the 2-bit-per-base
// packed code of its string key (see `crate::tables::pack`), enabling
// binary-search lookups without any runtime table construction.
"""


def pack(key: str) -> int:
"""2-bit-per-base packing, LSB first (mirrors crates::tables::pack)."""
code = 0
for i, b in enumerate(key.upper()):
code |= BASE[b] << (2 * i)
return code


def gen_dict(name: str, table) -> str:
items = sorted(((pack(k), v) for k, v in table.items()), key=lambda kv: kv[0])
lines = [f"pub static {name}: &[(u32, f64)] = &["]
for code, val in items:
lines.append(f" ({code}u32, {val!r}),")
lines.append("];\n")
return "\n".join(lines)


def gen_vec(name: str, values) -> str:
lines = [f"pub static {name}: &[f64] = &["]
for v in values:
lines.append(f" {v!r},")
lines.append("];\n")
return "\n".join(lines)


def gen_scalar(name: str, value) -> str:
return f"pub static {name}: f64 = {value!r};\n"


def main() -> int:
out = [HEADER]
for rust_name, py_attr, kind in TABLES:
obj = getattr(P, py_attr)
gen = {"vec": gen_vec, "dict": gen_dict, "scalar": gen_scalar}[kind]
out.append(gen(rust_name, obj))
path = sys.argv[1] if len(sys.argv) > 1 else "native/src/tables_dna.rs"
with open(path, "w") as f:
f.write("\n".join(out))
total = sum(1 if k == "scalar" else len(getattr(P, a)) for _, a, k in TABLES)
print(f"wrote {path}: {len(TABLES)} tables, {total} entries")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading