dienr 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dienr-0.1.0/.gitattributes +2 -0
- dienr-0.1.0/.github/workflows/ci.yml +81 -0
- dienr-0.1.0/.github/workflows/publish.yml +79 -0
- dienr-0.1.0/.gitignore +83 -0
- dienr-0.1.0/.python-version +1 -0
- dienr-0.1.0/CITATION.cff +50 -0
- dienr-0.1.0/CLAUDE.md +215 -0
- dienr-0.1.0/CONTRIBUTING.md +118 -0
- dienr-0.1.0/LICENSE +21 -0
- dienr-0.1.0/PKG-INFO +144 -0
- dienr-0.1.0/README.md +110 -0
- dienr-0.1.0/docs/concepts.md +92 -0
- dienr-0.1.0/docs/differences-from-denr.md +53 -0
- dienr-0.1.0/docs/getting-started.md +179 -0
- dienr-0.1.0/docs/input-data.md +132 -0
- dienr-0.1.0/docs/likelihoods.md +78 -0
- dienr-0.1.0/docs/outputs.md +60 -0
- dienr-0.1.0/docs/runon-tails.md +174 -0
- dienr-0.1.0/docs/shape-profiles.md +615 -0
- dienr-0.1.0/notes/README.md +84 -0
- dienr-0.1.0/notes/denr-python-implementation-plan.md +2783 -0
- dienr-0.1.0/pyproject.toml +100 -0
- dienr-0.1.0/scripts/benchmark_compare.py +60 -0
- dienr-0.1.0/scripts/benchmark_data.py +153 -0
- dienr-0.1.0/scripts/benchmark_denr.R +112 -0
- dienr-0.1.0/scripts/benchmark_dienr.py +96 -0
- dienr-0.1.0/scripts/benchmark_shape_accuracy.py +410 -0
- dienr-0.1.0/scripts/export_archetypes.R +49 -0
- dienr-0.1.0/scripts/export_r_golden.R +200 -0
- dienr-0.1.0/scripts/k562_longread_accuracy.py +420 -0
- dienr-0.1.0/scripts/k562_longread_defaults.py +58 -0
- dienr-0.1.0/scripts/k562_longread_explore.py +53 -0
- dienr-0.1.0/scripts/k562_longread_headtohead.py +66 -0
- dienr-0.1.0/scripts/k562_longread_logmask.py +74 -0
- dienr-0.1.0/scripts/k562_longread_sweep.py +167 -0
- dienr-0.1.0/scripts/k562_longread_tailfix.py +76 -0
- dienr-0.1.0/scripts/k562_pasmask_trial.py +204 -0
- dienr-0.1.0/scripts/k562_pergene_trial.py +312 -0
- dienr-0.1.0/scripts/k562_runon_check.py +309 -0
- dienr-0.1.0/scripts/k562_tail_mask_diagnose.py +218 -0
- dienr-0.1.0/scripts/k562_tssmask_trial.py +158 -0
- dienr-0.1.0/scripts/oracle_shape_ceiling.py +455 -0
- dienr-0.1.0/scripts/probe_r_shape_profile.R +240 -0
- dienr-0.1.0/scripts/profile_k562.py +157 -0
- dienr-0.1.0/scripts/runon_diagnose.py +279 -0
- dienr-0.1.0/scripts/runon_overlap_trial.py +304 -0
- dienr-0.1.0/scripts/runon_trial.py +414 -0
- dienr-0.1.0/scripts/shape_runon_trial.py +249 -0
- dienr-0.1.0/scripts/simulate_nascentrnasim.R +106 -0
- dienr-0.1.0/scripts/validate_likelihoods.py +374 -0
- dienr-0.1.0/scripts/validate_shape_usefulness.py +294 -0
- dienr-0.1.0/src/dienr/__init__.py +121 -0
- dienr-0.1.0/src/dienr/annotation.py +268 -0
- dienr-0.1.0/src/dienr/cli.py +769 -0
- dienr-0.1.0/src/dienr/contigs.py +209 -0
- dienr-0.1.0/src/dienr/counts.py +585 -0
- dienr-0.1.0/src/dienr/design.py +696 -0
- dienr-0.1.0/src/dienr/diagnostics.py +113 -0
- dienr-0.1.0/src/dienr/loci.py +104 -0
- dienr-0.1.0/src/dienr/pipeline.py +464 -0
- dienr-0.1.0/src/dienr/results.py +456 -0
- dienr-0.1.0/src/dienr/runon.py +486 -0
- dienr-0.1.0/src/dienr/shape.py +823 -0
- dienr-0.1.0/src/dienr/solvers/__init__.py +39 -0
- dienr-0.1.0/src/dienr/solvers/_glm.py +177 -0
- dienr-0.1.0/src/dienr/solvers/_lbfgsb.py +180 -0
- dienr-0.1.0/src/dienr/solvers/inference.py +243 -0
- dienr-0.1.0/src/dienr/solvers/legacy.py +387 -0
- dienr-0.1.0/src/dienr/solvers/penalties.py +129 -0
- dienr-0.1.0/src/dienr/solvers/poisson.py +350 -0
- dienr-0.1.0/src/dienr/status.py +69 -0
- dienr-0.1.0/src/dienr/strand.py +195 -0
- dienr-0.1.0/tests/__init__.py +1 -0
- dienr-0.1.0/tests/conftest.py +96 -0
- dienr-0.1.0/tests/fixtures/__init__.py +1 -0
- dienr-0.1.0/tests/fixtures/bigwig.py +43 -0
- dienr-0.1.0/tests/fixtures/generate.py +47 -0
- dienr-0.1.0/tests/fixtures/parity/README.md +83 -0
- dienr-0.1.0/tests/fixtures/parity/both_strands_separate_loci.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/duplicate_models_collapse.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/masked_ends.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/minus_strand_orientation.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/multi_contig.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/quiet_locus.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/rank_deficient_union.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/separate_loci_by_distance.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/subbin_first_bin_masking.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/subbin_minus_strand_masking.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/terminal_partial_bin.json +1 -0
- dienr-0.1.0/tests/fixtures/parity/two_distinguishable_isoforms.json +1 -0
- dienr-0.1.0/tests/fixtures/scenarios.py +310 -0
- dienr-0.1.0/tests/test_annotation.py +254 -0
- dienr-0.1.0/tests/test_cli.py +859 -0
- dienr-0.1.0/tests/test_contigs.py +241 -0
- dienr-0.1.0/tests/test_counts.py +432 -0
- dienr-0.1.0/tests/test_design.py +498 -0
- dienr-0.1.0/tests/test_diagnostics.py +79 -0
- dienr-0.1.0/tests/test_inference.py +201 -0
- dienr-0.1.0/tests/test_legacy_solver.py +378 -0
- dienr-0.1.0/tests/test_loci.py +115 -0
- dienr-0.1.0/tests/test_parity.py +336 -0
- dienr-0.1.0/tests/test_parity_harness.py +259 -0
- dienr-0.1.0/tests/test_poisson.py +722 -0
- dienr-0.1.0/tests/test_results.py +713 -0
- dienr-0.1.0/tests/test_runon.py +659 -0
- dienr-0.1.0/tests/test_shape.py +1166 -0
- dienr-0.1.0/tests/test_strand.py +226 -0
- dienr-0.1.0/tests/test_version.py +25 -0
- dienr-0.1.0/uv.lock +1211 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
env:
|
|
17
|
+
# Fail rather than silently re-resolve when uv.lock is out of date.
|
|
18
|
+
UV_LOCKED: "1"
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
lint:
|
|
22
|
+
name: Lint and types
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v7
|
|
26
|
+
- uses: astral-sh/setup-uv@v10.3.3
|
|
27
|
+
with:
|
|
28
|
+
enable-cache: true
|
|
29
|
+
- run: uv sync --group dev
|
|
30
|
+
- run: uv run ruff check .
|
|
31
|
+
- run: uv run ruff format --check .
|
|
32
|
+
- run: uv run mypy
|
|
33
|
+
|
|
34
|
+
test:
|
|
35
|
+
name: Tests (${{ matrix.os }}, Python ${{ matrix.python }}${{ matrix.extra && format(', {0}', matrix.extra) || '' }})
|
|
36
|
+
runs-on: ${{ matrix.os }}
|
|
37
|
+
strategy:
|
|
38
|
+
fail-fast: false
|
|
39
|
+
matrix:
|
|
40
|
+
os: [ubuntu-latest]
|
|
41
|
+
python: ["3.11", "3.12", "3.13", "3.14"]
|
|
42
|
+
extra: [""]
|
|
43
|
+
include:
|
|
44
|
+
- os: macos-latest
|
|
45
|
+
python: "3.13"
|
|
46
|
+
extra: ""
|
|
47
|
+
# Polars' compatibility runtime (pre-AVX2 CPUs); polars prefers it once installed.
|
|
48
|
+
- os: ubuntu-latest
|
|
49
|
+
python: "3.13"
|
|
50
|
+
extra: compat
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v7
|
|
53
|
+
- uses: astral-sh/setup-uv@v10.3.3
|
|
54
|
+
with:
|
|
55
|
+
python-version: ${{ matrix.python }}
|
|
56
|
+
enable-cache: true
|
|
57
|
+
- run: uv sync --group dev ${{ matrix.extra && format('--extra {0}', matrix.extra) || '' }}
|
|
58
|
+
- name: Report runtime versions
|
|
59
|
+
run: uv run python -c "import sys, numpy, scipy, polars; print(sys.version); print('numpy', numpy.__version__, 'scipy', scipy.__version__, 'polars', polars.__version__)"
|
|
60
|
+
# _reference/ is absent here, so tests that need the R package's
|
|
61
|
+
# extdata skip; the committed parity goldens still run.
|
|
62
|
+
- run: uv run pytest -q
|
|
63
|
+
|
|
64
|
+
build:
|
|
65
|
+
name: Build and smoke-test the distributions
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
steps:
|
|
68
|
+
- uses: actions/checkout@v7
|
|
69
|
+
- uses: astral-sh/setup-uv@v10.3.3
|
|
70
|
+
- run: uv build
|
|
71
|
+
- name: Install the wheel into a clean environment
|
|
72
|
+
run: |
|
|
73
|
+
uv venv --no-project /tmp/smoke
|
|
74
|
+
uv pip install --python /tmp/smoke/bin/python dist/*.whl
|
|
75
|
+
/tmp/smoke/bin/dienr --help
|
|
76
|
+
/tmp/smoke/bin/python -c "import dienr; print(dienr.__name__)"
|
|
77
|
+
- uses: actions/upload-artifact@v7
|
|
78
|
+
with:
|
|
79
|
+
name: dist
|
|
80
|
+
path: dist/
|
|
81
|
+
retention-days: 7
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# A published GitHub release uploads to PyPI; a manual run uploads to TestPyPI.
|
|
4
|
+
# Both use trusted publishing (OIDC), so no API token is stored. One-time setup:
|
|
5
|
+
# add this repository as a trusted publisher on pypi.org and test.pypi.org
|
|
6
|
+
# (workflow "publish.yml", environments "pypi" and "testpypi"), and create both
|
|
7
|
+
# environments under Settings -> Environments.
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
release:
|
|
11
|
+
types: [published]
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
build:
|
|
19
|
+
name: Build distributions
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v7
|
|
23
|
+
- uses: astral-sh/setup-uv@v10.3.3
|
|
24
|
+
- name: Check the release tag matches the package version
|
|
25
|
+
if: github.event_name == 'release'
|
|
26
|
+
env:
|
|
27
|
+
TAG: ${{ github.event.release.tag_name }}
|
|
28
|
+
run: |
|
|
29
|
+
version=$(uv run --no-project python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
30
|
+
if [ "${TAG#v}" != "$version" ]; then
|
|
31
|
+
echo "::error::release tag $TAG does not match pyproject version $version"
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
- run: uv build
|
|
35
|
+
- name: Smoke-test the wheel
|
|
36
|
+
run: |
|
|
37
|
+
uv venv --no-project /tmp/smoke
|
|
38
|
+
uv pip install --python /tmp/smoke/bin/python dist/*.whl
|
|
39
|
+
/tmp/smoke/bin/dienr --help
|
|
40
|
+
- uses: actions/upload-artifact@v7
|
|
41
|
+
with:
|
|
42
|
+
name: dist
|
|
43
|
+
path: dist/
|
|
44
|
+
|
|
45
|
+
pypi:
|
|
46
|
+
name: Upload to PyPI
|
|
47
|
+
if: github.event_name == 'release'
|
|
48
|
+
needs: build
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
environment:
|
|
51
|
+
name: pypi
|
|
52
|
+
url: https://pypi.org/p/dienr
|
|
53
|
+
permissions:
|
|
54
|
+
id-token: write
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/download-artifact@v8
|
|
57
|
+
with:
|
|
58
|
+
name: dist
|
|
59
|
+
path: dist/
|
|
60
|
+
- uses: pypa/gh-action-pypi-publish@v1.14.3
|
|
61
|
+
|
|
62
|
+
testpypi:
|
|
63
|
+
name: Upload to TestPyPI
|
|
64
|
+
if: github.event_name == 'workflow_dispatch'
|
|
65
|
+
needs: build
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
environment:
|
|
68
|
+
name: testpypi
|
|
69
|
+
url: https://test.pypi.org/p/dienr
|
|
70
|
+
permissions:
|
|
71
|
+
id-token: write
|
|
72
|
+
steps:
|
|
73
|
+
- uses: actions/download-artifact@v8
|
|
74
|
+
with:
|
|
75
|
+
name: dist
|
|
76
|
+
path: dist/
|
|
77
|
+
- uses: pypa/gh-action-pypi-publish@v1.14.3
|
|
78
|
+
with:
|
|
79
|
+
repository-url: https://test.pypi.org/legacy/
|
dienr-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# --- dienr-specific --------------------------------------------------------
|
|
2
|
+
|
|
3
|
+
# Frozen R reference implementation: a nested git clone, kept locally as the
|
|
4
|
+
# behavioral reference. Not vendored because its LICENSE.md (MIT) and
|
|
5
|
+
# DESCRIPTION (GPL >=2) disagree; see plan Phase 0.
|
|
6
|
+
_reference/
|
|
7
|
+
|
|
8
|
+
# Scratch notebooks and profiling output
|
|
9
|
+
*.prof
|
|
10
|
+
scratch/
|
|
11
|
+
|
|
12
|
+
# --- Python ----------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
__pycache__/
|
|
15
|
+
*.py[cod]
|
|
16
|
+
*$py.class
|
|
17
|
+
*.so
|
|
18
|
+
|
|
19
|
+
# Packaging
|
|
20
|
+
.Python
|
|
21
|
+
build/
|
|
22
|
+
develop-eggs/
|
|
23
|
+
dist/
|
|
24
|
+
downloads/
|
|
25
|
+
eggs/
|
|
26
|
+
.eggs/
|
|
27
|
+
sdist/
|
|
28
|
+
wheels/
|
|
29
|
+
share/python-wheels/
|
|
30
|
+
*.egg
|
|
31
|
+
*.egg-info/
|
|
32
|
+
.installed.cfg
|
|
33
|
+
MANIFEST
|
|
34
|
+
|
|
35
|
+
# Environments
|
|
36
|
+
.venv/
|
|
37
|
+
venv/
|
|
38
|
+
env/
|
|
39
|
+
ENV/
|
|
40
|
+
|
|
41
|
+
# Test and coverage
|
|
42
|
+
.pytest_cache/
|
|
43
|
+
.tox/
|
|
44
|
+
.nox/
|
|
45
|
+
.cache
|
|
46
|
+
htmlcov/
|
|
47
|
+
.coverage
|
|
48
|
+
.coverage.*
|
|
49
|
+
coverage.xml
|
|
50
|
+
*.cover
|
|
51
|
+
nosetests.xml
|
|
52
|
+
|
|
53
|
+
# Type checkers and linters
|
|
54
|
+
.mypy_cache/
|
|
55
|
+
.dmypy.json
|
|
56
|
+
dmypy.json
|
|
57
|
+
.ruff_cache/
|
|
58
|
+
.pyre/
|
|
59
|
+
.pytype/
|
|
60
|
+
|
|
61
|
+
# Notebooks
|
|
62
|
+
.ipynb_checkpoints
|
|
63
|
+
profile_default/
|
|
64
|
+
ipython_config.py
|
|
65
|
+
|
|
66
|
+
# Docs
|
|
67
|
+
docs/_build/
|
|
68
|
+
site/
|
|
69
|
+
|
|
70
|
+
# --- Editors and OS --------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
.DS_Store
|
|
73
|
+
.idea/
|
|
74
|
+
.vscode/
|
|
75
|
+
*.swp
|
|
76
|
+
|
|
77
|
+
# Cursor: `.cursorignore` excludes files from AI features. Recommended for
|
|
78
|
+
# sensitive data. https://docs.cursor.com/context/ignore-files
|
|
79
|
+
.cursorignore
|
|
80
|
+
.cursorindexingignore
|
|
81
|
+
|
|
82
|
+
# ignore generated parity inputs
|
|
83
|
+
build/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
dienr-0.1.0/CITATION.cff
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: >-
|
|
3
|
+
If you use dienr, please cite it as below, and also cite the DENR paper, whose
|
|
4
|
+
method dienr reimplements.
|
|
5
|
+
title: "dienr: Deconvolution of Isoform Expression from Nascent RNAs"
|
|
6
|
+
abstract: >-
|
|
7
|
+
A Python reimplementation of DENR that explains nascent RNA sequencing signal
|
|
8
|
+
(PRO-seq, GRO-seq) at each locus as a nonnegative mixture of pre-RNA isoform
|
|
9
|
+
models, with reusable designs, explicit identifiability, count likelihoods and
|
|
10
|
+
run-on tails.
|
|
11
|
+
type: software
|
|
12
|
+
authors:
|
|
13
|
+
- family-names: He
|
|
14
|
+
given-names: Adam Youlin
|
|
15
|
+
email: ayhe@stanford.edu
|
|
16
|
+
version: 0.1.0
|
|
17
|
+
license: MIT
|
|
18
|
+
repository-code: "https://github.com/adamyhe/dienr"
|
|
19
|
+
keywords:
|
|
20
|
+
- nascent RNA
|
|
21
|
+
- PRO-seq
|
|
22
|
+
- GRO-seq
|
|
23
|
+
- pre-RNA isoforms
|
|
24
|
+
- transcription
|
|
25
|
+
- bioinformatics
|
|
26
|
+
references:
|
|
27
|
+
- type: article
|
|
28
|
+
title: >-
|
|
29
|
+
Deconvolution of expression for nascent RNA-sequencing data (DENR)
|
|
30
|
+
highlights pre-RNA isoform diversity in human cells
|
|
31
|
+
authors:
|
|
32
|
+
- family-names: Zhao
|
|
33
|
+
given-names: Yixin
|
|
34
|
+
- family-names: Dukler
|
|
35
|
+
given-names: Noah
|
|
36
|
+
- family-names: Barshad
|
|
37
|
+
given-names: Gilad
|
|
38
|
+
- family-names: Toneyan
|
|
39
|
+
given-names: Shushan
|
|
40
|
+
- family-names: Danko
|
|
41
|
+
given-names: Charles G.
|
|
42
|
+
- family-names: Siepel
|
|
43
|
+
given-names: Adam
|
|
44
|
+
journal: Bioinformatics
|
|
45
|
+
volume: 37
|
|
46
|
+
issue: 24
|
|
47
|
+
start: 4727
|
|
48
|
+
end: 4736
|
|
49
|
+
year: 2021
|
|
50
|
+
doi: 10.1093/bioinformatics/btab582
|
dienr-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## What this repository is
|
|
6
|
+
|
|
7
|
+
`dienr` — "Deconvolution of Isoform Expression from Nascent RNAs" — is a **greenfield Python reimplementation** of the R/Bioconductor package [DENR](https://github.com/CshlSiepelLab/DENR) (Zhao & Dukler, *Bioinformatics* 2021). The method deconvolves nascent RNA sequencing signal (PRO-seq, GRO-seq, etc., processed so each read is a single 3'-end count) into abundances of annotated pre-RNA isoform models.
|
|
8
|
+
|
|
9
|
+
**Current state:** the legacy pipeline is implemented end to end in `src/dienr/` with R parity established (see *Implementation status*). Two things live outside the package:
|
|
10
|
+
|
|
11
|
+
- `notes/denr-python-implementation-plan.md` — the design document (tracked). **Read the relevant section before changing behavior**; it is the binding spec for architecture, schemas, phases and policy, and its decision gates (§17) and shape-profile findings (§18) record *why* things are the way they are.
|
|
12
|
+
- `_reference/DENR/` — an **untracked** clone of the R reference implementation, pinned at commit `09d6cde` (`master`, 2025-04-18), plus `nascentRNASim`. Behavioral reference for parity and fixture generation; a nested git repo, never commit into it or stage it. **It may be absent from a given checkout** — then the tests that need its extdata skip (see *How the suite is structured*); the committed parity goldens still run.
|
|
13
|
+
|
|
14
|
+
## Scope (plan §18.27)
|
|
15
|
+
|
|
16
|
+
**dienr replicates DENR, with its bugs fixed. The defaults are the only recommendation.** Poisson/quasi-Poisson, `--log-offset`, the run-on tail, shape-profile reweighting and penalties are kept, tested and documented, but labelled *experimental* — none improved isoform calls enough against K562 long reads (§18.20–18.26). Improvement work is paused by the maintainer's decision; don't start new method experiments or change recommendations without being asked. In scope: correctness, bug fixes, input checks, speed, packaging.
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
**Packaging:** `dienr` is released on PyPI; user docs install with `uv tool install dienr` / `uv add dienr`. From-source setup, tests, parity regeneration and the experiment conventions live in `CONTRIBUTING.md` — keep dev instructions there, not in `docs/` or the README. `CITATION.cff` cites dienr and the DENR paper (doi:10.1093/bioinformatics/btab582); bump its `version` with `pyproject.toml`.
|
|
21
|
+
|
|
22
|
+
**Documentation layout:** `docs/` is the user guide (getting started, concepts, likelihoods, run-on tails, shape profiles, outputs, differences from DENR) — current recommendations only. `notes/` holds the plan: binding spec plus append-only validation record; `notes/README.md` gives its conventions (pre-registered rules, visible corrections, recorded seeds). **Never split or renumber the plan** — its section numbers are cited from code, tests, scripts, commits and PRs. When a recommendation changes, update `docs/` *and* add a plan subsection.
|
|
23
|
+
|
|
24
|
+
Dependencies are managed with **uv**. The package lives in `src/dienr/`, tests in `tests/`.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv sync --group dev # create/refresh .venv from uv.lock
|
|
28
|
+
uv run pytest -q # full suite
|
|
29
|
+
uv run pytest tests/test_design.py::test_bins_are_anchored_at_locus_start # one test
|
|
30
|
+
uv run ruff check . && uv run ruff format .
|
|
31
|
+
uv run mypy # config in pyproject; files list is set there
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Core dependencies are NumPy/SciPy, Polars, `figwig` (all BigWig access; pulls in numba) and pydantic — deliberately no genomics interval/annotation library. The only extra is `compat` (`polars[rtcompat]`), for pre-AVX2 CPUs such as cbsubscb02–12: Polars' default runtime dies there with `Illegal instruction`, and once installed polars prefers the compat runtime everywhere. The `bam`, `intervals` (polars-bio) and `ml` (torch/anndata) extras were removed as unused; pandas is not a dependency at all. Python ≥ 3.11 (suite verified on 3.11–3.14, each resolved fresh — 3.11 gets numpy 2.4/scipy 1.17; 3.10 is EOL, Polars 2 needs 3.11, and `StrEnum` is 3.11+; mypy targets 3.12 for numpy's stubs); figwig is pinned `<0.4` (pre-1.0, all BigWig I/O) and polars `<3`. Verified on polars 2.1.0: full suite, parity 12/12, mypy.
|
|
35
|
+
|
|
36
|
+
Tests that need the R reference's synthetic BigWigs skip cleanly when `_reference/` is absent (see `tests/conftest.py`).
|
|
37
|
+
|
|
38
|
+
The R reference package can be exercised for fixture generation and parity checks (`R` and `Rscript` are on PATH via miniforge; the Bioconductor dependency stack may not be installed — check before assuming):
|
|
39
|
+
|
|
40
|
+
`scripts/probe_r_shape_profile.R` regenerates every number in plan §18 (the shape-profile defects) against the reference; `SHAPE_PROBE_TRIALS` raises the `apply_shape_profile` fuzz, which dominates its runtime.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# From _reference/DENR/
|
|
44
|
+
Rscript -e 'devtools::test()' # full testthat suite
|
|
45
|
+
Rscript -e 'devtools::test(filter = "model_fitting")' # one test file (tests/testthat/test-<filter>.R)
|
|
46
|
+
Rscript -e 'devtools::load_all(); ...' # interactive use of the reference API
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Test fixtures live in `_reference/DENR/inst/extdata/`: synthetic stranded BigWigs plus matching `.txdb` annotations (single-strand, double-strand, multichrom, transcript-filter, UPR cases). `tests/testthat/setup-model_fitting.R` shows the canonical end-to-end R invocation and is the model for what Phase 0 must export as golden fixtures.
|
|
50
|
+
|
|
51
|
+
## Reference pipeline (what is being reimplemented)
|
|
52
|
+
|
|
53
|
+
The R package's flow, and the source files to consult for each stage:
|
|
54
|
+
|
|
55
|
+
1. **Annotation → loci → design matrices** (`R/transcript_models_build_helpers.R`, `R/transcript_quantifier-class.R`). Transcripts are grouped into strand-specific connected components (`group_transcripts`), binned at `bin_size` (default 250bp), converted to a per-locus matrix of fractional bin overlap (`create_transcript_models`), TSS/PAS-adjacent bins masked (`create_model_masks`, typically ±1kb), and then indistinguishable transcripts are collapsed to shared models (`reduce_transcript_models`). The result is the S4 `transcript_quantifier` ("tq") object: per-locus `models` matrices, `masks`, `counts`, `model_abundance`, and a `transcript_model_key` mapping transcript → locus group → model column.
|
|
56
|
+
2. **Signal extraction** (`R/data_handling.R`). `add_data`/`summarize_bigwig` aggregate stranded BigWigs into per-bin count vectors.
|
|
57
|
+
3. **Optional shape profile** (`R/transcript_shape_profile-class.R`). A loess fit of relative polymerase density vs. rescaled gene-body position, applied to reweight model matrices away from the uniform-density assumption.
|
|
58
|
+
4. **Optional inactive-TSS filtering** (`R/tss_predictor.R`, `R/upstream_density_ratio.R`). A bundled Keras CNN predicts inactive TSSs; two heuristics (upstream polymerase ratio ≥ log2(5), and a goodness-of-fit rescue at all-inactive loci) can override the classifier. Inactive models are pinned to ~zero via optimizer upper bounds.
|
|
59
|
+
5. **Fitting** (`R/model_fitting.R:110`). Per locus, `stats::optim(method = "L-BFGS-B")` minimizes sum-of-squares between observed and `models %*% abundance`, under either `transform = "identity"` or the default `transform = "log"` (log of value + 1e-3). Only `opt_result$par` is retained.
|
|
60
|
+
6. **Reporting** (`R/abundance_tables.R`, `R/model_fit_statistics.R`). `transcript_abundance` repeats a shared model's abundance across every transcript mapped to it.
|
|
61
|
+
|
|
62
|
+
## Implementation status
|
|
63
|
+
|
|
64
|
+
The legacy path is complete end to end: `annotation` (GTF/GFF → pre-RNA spans) → `loci` → `design` (bins, masks, model reduction) → `counts` (BigWig aggregation) → `solvers/legacy` → `pipeline.fit` → `results` (versioned Parquet + manifest), with `cli` exposing `build-design` / `count` / `fit` / `shape-profile` / `validate`.
|
|
65
|
+
|
|
66
|
+
`shape.py` implements empirical shape profiles (plan §18): `fit_shape_profile` measures relative polymerase density from isolated, expressed genes and `apply_shape_profile` reweights a `Design`'s model columns by it, which is what makes `M` non-binary. This was a v1 non-goal until Gate B and Gate D both reduced to needing it.
|
|
67
|
+
|
|
68
|
+
`runon.py` implements run-on tails (plan §18.10–18.15; **not recommended for isoform calls, §18.26**): `fit_runon_tail` estimates one shared read-through tail past the PAS from isolated single-isoform genes and `apply_runon_tail` adds it to each model column past its pre-mask span (`Design.runon_digest`; CLI `dienr runon-tail` and `build-design --runon-tail`). The one design change validated on simulated *and* real data; recommended with Poisson.
|
|
69
|
+
|
|
70
|
+
Two solvers: `solvers/legacy` (default, the only one with R parity) and `solvers/poisson`, which also serves `likelihood="quasi_poisson"` (same fit, standard errors scaled by a pooled dispersion via `solvers/inference`). Plus `solvers/penalties` (ridge/lasso/elastic net; **unvalidated — Gate C was never run and there is no λ selection**) and the shared `solvers/_glm` exposure machinery. Selected via `fit(likelihood=...)` or `dienr fit --likelihood`. **The negative-binomial solver was removed** (Gate D; see *Design decisions*).
|
|
71
|
+
|
|
72
|
+
**BAM input is deliberately unsupported** (plan §20): chemistry-specific conversion belongs upstream; `docs/input-data.md` has guidance, and `dienr count` runs `strand.check_strand_orientation` on every sample, refusing a swapped plus/minus pair (§18.16). **Contig renaming** is `contigs.py`: `--contig-map FILE|PRESET` on `build-design`/`runon-tail`/`shape-profile`, applied to the annotation (new digest), recorded as `Design.contig_map` and in the manifest; never merges contigs; presets cover primary human chromosomes only; ChromosomeMappings files load as-is (empty target = no equivalent). `count` refuses when *no* locus contig is in the BigWigs, suggesting a preset. **Not yet written:** TSS-evidence mapping, normalization factor computation, the multi-sample group penalty, and ML adapters beyond `FitResult.to_numpy`.
|
|
73
|
+
|
|
74
|
+
**R parity is established: 12/12 comparable scenarios verified** against DENR 1.0.0 at commit `09d6cde`. Locus membership, bin boundaries, mask indices, the reduced design matrix, equivalence classes, binned signal, predicted signal, objective values and full-rank abundances all agree. Coverage is printed on every run, so a regression to zero cannot pass unnoticed.
|
|
75
|
+
|
|
76
|
+
## Performance status
|
|
77
|
+
|
|
78
|
+
Measured against R DENR on identical data (`scripts/benchmark_*`, plan §14.2), model counts agreeing exactly:
|
|
79
|
+
|
|
80
|
+
| regime | total | fitting | aggregation | design | peak RSS |
|
|
81
|
+
|---|---|---|---|---|---|
|
|
82
|
+
| dense (124 loci, ~18 tx/locus) | **14.9×** | 9.3× | 24× | 27× | 301 MB vs 2417 MB (8.0×) |
|
|
83
|
+
| sparse (955 loci, ~3 models/locus) | **14.5×** | 3.8× | 38× | 14× | 249 MB vs 2265 MB (9.1×) |
|
|
84
|
+
|
|
85
|
+
Both the 3× wall-time and 2× memory targets are met. RSS is measured externally (`/usr/bin/time -l`) for both sides; R has no usable in-process figure since `gc()` reports heap cells, not RSS.
|
|
86
|
+
|
|
87
|
+
**Genome scale (plan §14.4):** a whole-genome protein-coding run is ~30 s on one node. `fit` caps BLAS at one thread by default (`blas_threads`, `--blas-threads`; multithreaded BLAS made fitting 1.26–1.46× slower on tiny matrices). `build_design` slices loci from one stable sort and vectorizes masks — 2.3× faster, bit-identical (5.49 → 2.35 s protein-coding). Cold imports from a Lustre `.venv` cost ~24 s per fresh process — **figwig is now imported lazily** (`counts.open_bigwig`; never import figwig at module top), so `import dienr.cli` went 15.9 → 1.8 s cold (§14.5). **`fit(n_jobs=)` / `--jobs`** fits loci in worker processes, identical output for any N; 7.7 → 2.9 s (legacy) at 8, flat past 4 (~2.5 s serial), so CLI default 4 (affinity-capped), library 1. `_start_context` forks if every other Python thread is a pool worker (figwig's idle shared pool), else forkserver — forkserver/spawn re-import an unguarded `__main__` and crash. **Benchmark only on Slurm, comparing on one node** — cbsubscb16 ran ~4× slower than cbsubscb20 at every stage.
|
|
88
|
+
|
|
89
|
+
**Two performance traps this codebase has already fallen into**, both recorded in §14.2:
|
|
90
|
+
|
|
91
|
+
1. `Design.mask()` once filtered the `bins` DataFrame instead of indexing a cached list. It runs once per locus per sample from both `count_sample` and `collect_results`, and cost 5.57 s of a 5.92 s fitting stage — it alone was responsible for sparse fitting being *slower than R*. `Design` now caches `masks` and `bin_lengths` as lists; **any new per-locus accessor must index, never scan.**
|
|
92
|
+
2. Profiling a component instead of a stage sent the first fix at the wrong target. The `scipy.optimize.minimize` overhead was real (the lean `solvers/_lbfgsb` driver is 2.8× faster per call and bit-identical) but moved end-to-end sparse time by only 12%.
|
|
93
|
+
|
|
94
|
+
**JIT: evaluated twice, declined twice, for different reasons** (plan §14.1 and §14.3). Now that the scaffolding is gone the fused closures are 61–68% of each fitting stage, and numba would give **2.1× Poisson / 2.2× NB / 1.9× legacy** at the realistic 329×3 locus — but **0.3× (three times slower) at 4000×30**, where a hand loop can't beat BLAS. Declined because it needs size dispatch plus a second bit-identical implementation of every likelihood, for ~1.2× end-to-end on a pipeline already 14.5× faster than R. The free part was taken: fused closures fold the usable mask into precomputed vectors and preallocate all temporaries (~1.18× on Poisson/NB stages, no regression at any size). Don't add a JIT kernel without re-reading §14.3.
|
|
95
|
+
|
|
96
|
+
## How the suite is structured
|
|
97
|
+
|
|
98
|
+
740 tests, 97% line coverage in a checkout without `_reference/`. **37 skip there**: the 10 `absent_contig` tests (R cannot produce a golden for that scenario) plus every test that reads the R package's extdata BigWigs. With `_reference/` present only the 10 remain. New tests should generate their own inputs (`tests/fixtures/scenarios.py`) so they always run. `uv run pytest -q --cov=dienr --cov-report=term-missing` for the breakdown.
|
|
99
|
+
|
|
100
|
+
The layers, roughly in order of how much they buy:
|
|
101
|
+
|
|
102
|
+
1. **Cross-implementation parity (106 tests).** Eight comparison dimensions × 13 scenarios against frozen R DENR goldens. An independent implementation as oracle is the strongest evidence available here.
|
|
103
|
+
2. **Meta-tests that the parity checks can fail (19).** `test_parity_harness.py` corrupts goldens deliberately — shifts bins by one, transposes the matrix, blanks the masks, splits equivalence classes — and asserts each comparison catches it. Without these a green parity suite could be vacuous.
|
|
104
|
+
3. **Fast-path ≡ reference-path equivalence.** The lean L-BFGS-B driver is bit-identical to `scipy.optimize.minimize`; each fused closure equals its readable `objective`/`gradient` pair; the single-model fast path equals the general optimizer. This is what makes aggressive optimization safe without risking parity.
|
|
105
|
+
4. **Analytic gradients vs finite differences**, for all three likelihoods × penalty combinations.
|
|
106
|
+
5. **Known-truth recovery**, plus "error must fall as depth rises".
|
|
107
|
+
6. **Every `FitStatus` reached deliberately**, asserting NaN-not-zero — §10 encoded as tests.
|
|
108
|
+
7. **Findings pinned as regressions.** Every bug and surprise in the log has a test: the `ALL_ZERO` short-circuit, mask anchoring order, the uncached `mask()`, the pinning collapse, the ridge scaling law, `legacy_log`'s low-count misspecification.
|
|
109
|
+
8. **Derived rather than magic constants** — e.g. shrinkage ≈ `2λa/n_bins` is asserted against its prediction, so the thresholds in neighbouring tests are interpretable.
|
|
110
|
+
|
|
111
|
+
**Known gaps, deliberately.** No property-based testing (all example-based); no mutation testing, so coverage ≠ assertion quality; no real biological data — everything is synthetic or the R package's synthetic fixtures; no concurrency tests since there is no parallelism yet. The parity goldens also come from newer Bioconductor than the container, so parity is against a near-neighbour of the frozen reference, not the frozen reference itself.
|
|
112
|
+
|
|
113
|
+
## Likelihood guidance (Gate B evidence)
|
|
114
|
+
|
|
115
|
+
`scripts/validate_likelihoods.py` compares the objectives on prespecified regimes with contiguous held-out blocks and paired per-fold scoring (plan §17, Gate B). Stable across seeds:
|
|
116
|
+
|
|
117
|
+
- **`transform="log"` — the reference's default — is badly misspecified once zeros are common.** At mean 0.5 counts/bin its held-out deviance is ~3.1 vs ~1.2 for everything else. A `y = 0` bin contributes `[log(1e-3) − log(μ+1e-3)]²` ≈ 38 at μ=0.5, so empty bins dominate and drag the fit toward zero. Prefer `transform="identity"` for sparse data.
|
|
118
|
+
- **Poisson's edge over `legacy_identity` is small, and there is an exact reason.** The two estimating equations differ only by a weight `1/μ_b`, so when `μ` is constant on a model's support they are *the same equation*. For a single 0/1 column they are literally the same estimator (measured difference `0.000e+00`). **`bin_operation` rounding to 0/1 is what destroys the advantage** — with overlapping indicator columns `μ` takes just two values across a locus. So: **a graded design is a prerequisite for Poisson to pay off on accuracy**, and `shape.py` now supplies one.
|
|
119
|
+
- **The old "≤1.12× ceiling, ~6% in SE" figure was wrong — see plan §17 (Gate B).** `Var(MSE)/Var(Poisson) = ΣM³ΣM/(ΣM²)²` is driven by **kurtosis, not range**: for a *uniform* `M` it is ~1.12 whether the range is 39× or 50×. A real pausing profile is the outlier case (one bin far above a flat remainder), where it is much larger. Measured on a reshaped design over 200 Poisson resamplings: binary → SE ratio **1.000** (identical estimators); reshaped, 14.3× range → **1.516**, against 1.526 predicted from that `M`. **The prize is ~50% in SE, not 6%** — but only on a graded design.
|
|
120
|
+
- **Never ship a fixed penalty weight.** Relative shrinkage follows **`2λa/n_bins`** (verified within 25%), so `ridge(0.01)` is negligible at abundance ~1 and catastrophic at ~200 — held-out deviance 68.7 vs 1.06, winning 1% of folds. `λ ∝ 1/a` cancels the dependence. `--lambda2` has no nonzero default on purpose.
|
|
121
|
+
|
|
122
|
+
## Shape profiles (plan §18)
|
|
123
|
+
|
|
124
|
+
User-facing documentation is `docs/shape-profiles.md`. **Validated on the DENR authors' own simulator** (`nascentRNASim`, MIT, cloned into `_reference/`): `scripts/simulate_nascentrnasim.R` → `scripts/benchmark_shape_accuracy.py`. The paper has **no real-data gold standard** — §3.2 says so — so its own benchmark *is* this simulator, and it is reproducible. Two seeds, paper's masking, contig holdout, one global calibration constant removed per arm (§18.6):
|
|
125
|
+
|
|
126
|
+
- **A shape profile is mostly NOT worth applying — five-seed paired result, §18.8.** Superseded an earlier two-seed "improves ranking" claim of mine. On multi-model loci, paired: dominant-isoform **wash** (0.53/0.50 share of discordant loci), magnitude **wash** (0.50), within-locus rho +0.022 but **sign-flips on 2/5 seeds**. The *only* effect replicating 5/5 is **~9% fewer models collapsing to exactly zero** (18.9%→17.1%) — and that is also what produced the apparent rho gain, since zeros are a bottom tie block (pooled rho +0.026 with zeros, +0.013 without, sign-unstable). Best dominant accuracy in that run was `binary/legacy_log` (0.761), but **on the recorded seeds `shape/legacy_log` edges it (0.761 vs 0.757) — a seed draw; see §18.11**. Three of 18.8's five seeds were never recorded.
|
|
127
|
+
- **Oracle ceiling (plan §18.9, corrected by §18.10): a density model *could* help, and run-on is the estimable part.** With every isoform's *true* density known (exact, from nascentRNASim's archetypes), dominant accuracy on ~790 multi-model loci/seed goes 0.698 binary/poisson (DENR default 0.731) → 0.745 head/body step → 0.822 smooth per-gene shape → 0.877 exact → 0.976 adding run-on past the PAS. Body shape is ~0.18 of the gap, run-on ~0.10. A shared profile captures almost none of the body part (0.715); gene-specific shape has no estimator. `scripts/oracle_shape_ceiling.py` is the harness. An earlier version said "most of the ceiling is read-through" and "absent" — both artifacts of the solver bug below, which restricted scoring to ~200 easy loci.
|
|
128
|
+
- **A fitted run-on tail passes its pre-registered rule (plan §18.10):** one shared tail `r(d)` past each model's PAS, fitted as a ratio of sums over isolated single-isoform loci, raises dominant accuracy 0.697 → **0.833** (exact tail 0.886), wins 81% of discordant loci vs binary/poisson and 76% vs DENR's default, lower error at 58–62% of loci in every seed. The fitted tail *rises* to ~2.4× body density at 1.5–2 kb past the PAS (termination-zone pile-up, beyond the 1 kb PAS mask). An "overlap-only" tail is the *same* design (§18.12): locus windows are unions of transcript spans, so every in-window tail bin already overlaps another model. The earlier "loss where the tail lands on nothing" was a calibration artifact — fits there are bit-identical, and a per-arm calibration constant made them score differently. The surviving residual loss is gene-specific tail size (shared tail > 2× true: error wins 0.486). `scripts/runon_trial.py`, `scripts/runon_diagnose.py`. Simulator truncates run-on at 6 kb; not a package feature yet.
|
|
129
|
+
- **Real data supports the run-on tail (plan §18.14).** K562 PRO-seq (GSE96869, 0 min, 2 replicates; tail fitted on one, scored on the other): held-out Poisson deviance at ~8,300 multi-model loci is **−5.5% / −5.4%** vs binary, lower at 57% of loci in both — passes its pre-registered rule. The real tail peaks ~1.4× body density at ~1.25 kb and is still ~0.15 at 15 kb; **27% of its mass lies past nascentRNASim's 6 kb cutoff**, and cutting it at 6 kb keeps only ~58% of the gain. A shared shape profile is +16% *worse* in total held-out deviance on real data. `scripts/k562_runon_check.py`. Next: run-on as an opt-in package feature, tail fitted to ≥15 kb.
|
|
130
|
+
- **Per-locus tail scale and depth gate: neither helps (plan §18.17).** A per-locus scale from an isolated downstream window made held-out K562 deviance +0.6% worse than the shared tail (wins 46–47%); a depth gate chosen on the other replicate picks "no gate" — the tail helps in every depth decile. An earlier "tail hurts low-signal loci" claim was a selection artifact (binned by the comparator's own held-out deviance). **Stratify by something independent of the outcome.**
|
|
131
|
+
- **With a run-on tail, drop the PAS mask (plan §18.18).** K562, every arm scored on the same `(4,4)`-usable test bins (a smaller mask only adds *training* bins): no PAS mask beats the paper's `mask_end_bins=(4,4)` by −4.1% / −3.9% held-out deviance, wins 64% / 63%. Most of it is the *inside* half — the last 1 kb of gene body — and even without a tail `(0,0)` beats `(4,4)` by 8%. The tail still adds ~2.2% at no mask. R-parity default stays `(4,4)`. **The TSS mask stays `(1,1)` (§18.19):** no mask is +10% worse, `(2,2)` lowers the total but wins <50% of loci on rep2 — the pause peak is signal a 0/1 column can't represent. Recommended for abundances (after §18.26): quasi-Poisson + `mask_start_bins=(1,1)` + no PAS mask, no tail. When comparing masks, never score each arm on its own usable bins — they differ.
|
|
132
|
+
- **Real-data isoform accuracy vs K562 long reads (plan §18.20): the pre-registered rule fails.** ENCODE PacBio ENCSR983KDL, matched to GRCh37 by Ensembl ID, ~2,970 decisive loci, chance 0.36. Recommended (tail, no PAS mask, Poisson) 0.778 / 0.745 vs DENR default 0.754 / 0.747 — wins rep1 only. **Replicated:** no PAS mask +4.7 points over the paper's mask (wins 73–75%). **Not replicated:** the tail's accuracy gain (+3.3 rep1, +0.3 rep2) — keep it for signal prediction, call its accuracy benefit unconfirmed. **Legacy log beats binary Poisson at dominant calls by ~5 points** on both replicates (also in simulation, §18.9) despite far worse held-out deviance: fit quality ≠ ranking. Don't claim Poisson calls isoforms better. `scripts/k562_longread_accuracy.py`.
|
|
133
|
+
- **Log objective + no PAS mask (plan §18.21): neither candidate passes.** `log_0_0` beats DENR default on both replicates (+2.3 / +2.2, wins 64–65%) but only ties `recommended` on rep1. **The PAS-mask gain has now held three ways** (deviance, Poisson accuracy, log accuracy). **The tail hurts with the log objective** (−3.2 / −3.5 points) — never combine `--runon-tail` with `likelihood=legacy`. Two configurations are indistinguishable for dominant calls; don't pick one post hoc.
|
|
134
|
+
- **The built-in defaults are DENR's defaults (plan §18.22); they beat DENR with the paper's masks — but see §18.23 for the tuned offset.** DENR's `transcript_quantifier` masks nothing by default, and neither do we; §18.20–18.21's `denr_default` arm was DENR *with the paper's masks* (1 TSS, 4 PAS bins) — a label error, corrected in §18.22. Defaults (log, no masks, `mean`) 0.771 / 0.769: beat paper masking on both replicates, match Poisson+tail; adding the 1-bin TSS mask makes no difference under log (p = 0.17 / 1.0). `docs/getting-started.md` recommends by goal: the tuned log configuration (§18.23) for dominant isoforms; **quasi-Poisson + `mask_start_bins=(1,1)` + no PAS mask (no tail, §18.26)** for magnitudes and standard errors. Don't change code defaults: they are R parity.
|
|
135
|
+
- **Tuned log vs Poisson config (plan §18.24): two configurations stay; Poisson+tail+TSS-mask collapses.** tuned log 0.771/0.773, identity LS ≈ same, defaults 0.728/0.730, `poisson_runon` **0.631/0.585** on 3,460 loci (earlier trials hid this: a PAS-4 arm merged nearby-APA isoforms out of the scored set). Exploratory: tail alone helps Poisson (+3.8/+2.1), TSS mask alone helps (+1.2/+1.4), **together they hurt (−4.9/−10.2 vs TSS alone)** — unexplained, possibly a defect in tail × mask; investigate loci where `poisson_tss` is right and `poisson_runon` wrong. The scored locus set depends on every arm's groups: **an arm with coarser equivalence classes silently drops loci for all arms.**
|
|
136
|
+
- **Run-on tail defect (plan §18.25): the locus window clips the longest isoform's tail.** Windows end at the most downstream PAS, so that isoform gets no tail while shorter ones get a tail peaking ~1.4 kb past their PAS — on the longer isoform's 3′ body. Where tail+TSS-mask Poisson is wrong and mask-only right, the truth's PAS is the window end in 92–93% (tail bins 0 vs the pick's 6), and the pick's PAS is ~1.4 kb upstream in 97%. Fix: extend windows downstream by the tail length when a tail is applied (clip at next same-strand locus/contig end). Until fixed, the tail biases calls toward shorter isoforms at nearby-APA loci.
|
|
137
|
+
- **Window extension fixes the asymmetry but not the tail (plan §18.26): run-on is not recommended for isoform calls.** `apply_runon_tail(extend=True)` (default) grows windows downstream by the tail length, stopping at the next same-strand locus; minus windows grow at the front, so read `Design.window_start(lid)`, never `loci["start"]`, for a window. Re-test: fix vs clipped ns on rep1, +2.7 rep2 (rule fails); Poisson+TSS without tail beats with tail on both (0.680/0.687 vs 0.629/0.611); with every tail visible the tail lowers accuracy in *every* configuration (log −7.7/−8.3). Abundances config is now quasi-Poisson + TSS 1 + no PAS mask, **no tail**; `build-design --runon-tail` warns. Run-on still improves signal fit (held-out deviance).
|
|
138
|
+
- **The log offset was the untuned knob that mattered (plan §18.23).** Sweep bin {100,250,500} × `c` {1e-4…1}, selected on rep1/odd chromosomes, confirmed on rep2/even: `b100_c1` 0.779 vs default 0.740 (wins 69%, p = 1e-5). `c` is the active ingredient — at 250 bp, 1e-3 → 1 is +3.4/+3.9 points; at `c = 1` bin size is within ~1 point (b250_c1 ≈ b100_c1, but choosing 250 would be post hoc). On the `mean` scale bins hold ~0.01–0.1, so `c = 1` makes the transform nearly linear and empty bins stop dominating. **Dominant-isoform recommendation: the defaults + `fit --log-offset 1` (250 bp kept — chosen after seeing the grid, so not independently confirmed; the rule's pick was 100 bp)**; code defaults stay DENR's. `legacy.fit_locus(log_offset=)`, `fit(log_offset=)`, `--log-offset` (legacy log only; recorded in the manifest).
|
|
139
|
+
- **Shape profile + run-on tail does not beat run-on alone (plan §18.13).** Paired vs `runon/poisson`: win share 0.539, error wins 0.43–0.46 every seed. The profile adds +0.7 dominant points and fewer zeros but costs magnitude (multi 0.283→0.319, single 0.078→0.185) — its §18.8 signature again. Next step is real data (§18.13 lists it), not more simulation.
|
|
140
|
+
- **Poisson's start must be scale-invariant — it wasn't.** `_glm.moment_initialization` used `M'y / M'scale` (the M-weighted mean of y), correct only for 0/1 columns and ~s² too high for entries of size s; on exact-density columns (~10² per bin) L-BFGS-B collapsed every abundance to the zero bound at 55–65% of loci (`invalid_mean`). Now `sum(y on support) / sum(scale·M)`, the single-model MLE; `test_fit_is_invariant_to_column_scale` pins it. Binary designs were unaffected. **When a harness pairs arms on a common locus set, check how many loci each arm drops** — that is how this hid.
|
|
141
|
+
- **Harness scoring must be order-independent (plan §18.11).** nascentRNASim abundances come from a discrete kernel grid, so truth ties are common, and zero estimates are a large tie block. Breaking ties by polars row order (not fixed after a join/`group_by`) made identical runs disagree. Spearman now uses average ranks (`scipy.stats.rankdata`); a dominant call is correct if the top estimate is *a* truly dominant model. Repeat runs are bit-identical; fits always were. Record every simulation seed you report.
|
|
142
|
+
- **The conditioning mechanism I proposed does not survive.** κ genuinely improves (7.44→4.98 median at multi-model loci) but that predicts a *within-locus* gain, and within-locus is exactly what's unstable. Better conditioning ≠ better accuracy here.
|
|
143
|
+
- **What it's actually for:** fewer spurious zeros; and the profile *as a measurement* (pausing index, 5′:3′ ratio, cross-sample comparison) which needs no reweighting at all — fit it, inspect `profile.table()`, stop. That's the paper's own Supp Fig S5 and the most defensible use. **Guidance: fit and inspect; be skeptical about applying.** If applying, use `poisson` and multi-model loci only.
|
|
144
|
+
- **Magnitudes don't improve, and that's not a defect — the estimand is a mean.** For `M ≡ 1`, `â = mean(y)`, unbiased for a mean-normalized truth *whatever the shape* (measured bias median **1.006**). A shared profile can only add misspecification: `â/a = ⟨g,p⟩/⟨p,p⟩`, and across-gene SD of the peak is **6.32** vs a shared `p(0)` of 4.06, while `p²` puts **26.7%** of its weight in the first 5% of the axis (uniform: 5.5%). The profile weights hardest where genes disagree most. What it buys is **conditioning** (median κ 7.44 → 4.98 at multi-model loci), which is why *ranking* improves. **At single-model loci shape is pure loss** — 4.5× worse error under LS, 2.4× under Poisson, no rho gain. Applying it only at multi-model loci is the obvious move and is **unvalidated** (a mixed design has two abundance scales, so the global calibration can't score it).
|
|
145
|
+
- **A graded design is what makes the likelihood matter.** Poisson vs identity least squares: **−1 to −5%** isoform error on a binary design, **−26 to −42%** on a reshaped one. Mechanism is Gate B's: the profile puts a 4–5× weight on the first bin, unweighted least squares is dominated by the largest design entries, Poisson's `1/μ` is the correction. **So: use a shape profile with `poisson`, not least squares** — `shape/poisson` is the only arm that buys ranking without paying in magnitude.
|
|
146
|
+
- **`span=0.4` is harmful, not just wasteful.** It drops isoform rho to 0.625, *below* the 0.673 of no profile at all; 0.05 raises it to 0.700.
|
|
147
|
+
- Caveat: nascentRNASim gives every simulated isoform an archetype's profile — the assumption a shape correction encodes — so this benchmark flatters shape modeling. No real data yet; the paper's orthogonal PRO-seq/RNA-seq entropy comparison is dbGaP `phs002146.v1.p1` (controlled). Public K562 `GSE96869` / CD4+T `GSE85337` would work and haven't been run.
|
|
148
|
+
|
|
149
|
+
The reference's shape profile crashes *and*, when it doesn't, does nothing. Both were reproduced against DENR 1.0.0 at `09d6cde`; §18 has the reproductions. The short version:
|
|
150
|
+
|
|
151
|
+
- **`grep("+", names)` is a regex**, and `+` matches every string, so the reference's "plus-strand groups" is *all* groups. It then reads the minus groups from `bigwig_plus` too, so **`bigwig_minus` never contributes signal to the profile at all**. Every minus gene enters the smoother twice, both times antisense, once backwards. On 255 simulated genes: **116 of 174 training curves identically zero, fitted dynamic range 1.25× against a true 14.63×.** The profile multiplies the design by ~1.0 — it is inert, not broken. Untestable in the reference by construction: every one of its shape-profile tests passes the same BigWig for both strands.
|
|
152
|
+
- **`min_percent_transcribed=0.8` is unsatisfiable on count data — this is the crash users hit.** `percent_transcribed` counts *bases* with ≥1 read (threshold hardcoded); PRO-seq 3′ counts are sparse, so on nascentRNASim output **1.07% of bases are nonzero** and the statistic peaks at 0.487. `transcript_shape_profile` therefore **fails outright on the authors' own simulator at their own defaults**. Lowering it doesn't help: at 0.4 exactly *one* gene survives and a loess profile is fitted from it silently.
|
|
153
|
+
- **The published "U shape" is largely an artifact.** Third strand defect: minus BigWigs are conventionally negative (their simulator writes min −1122); `add_data` applies `abs()`, `transcript_shape_profile` doesn't, so min–max inverts every minus gene. Two bugs hide each other — the minus file is never opened, so the missing `abs()` only fires once you fix the dispatch. Same 57 genes: DENR 5′/3′ = 1.33 (symmetric U) vs **2.13 (5′-peaked)** with all three fixed.
|
|
154
|
+
- **Two hard crashes.** `rescale_fixed_width_position` assigns a zero-length `seq` to `rescaled_pos[1:0]` ("replacement has length zero") whenever the tail is longer than the head and the gene is short; and `model_agreement` indexes a group table by `names(unlist(tx_grps))`, which is `"100_+.g227"` not `"100_+"`, so **any named input `GRanges` fails immediately** ("subscript contains invalid names"). It works only because `GenomicFeatures::transcripts()` happens to return unnamed ranges.
|
|
155
|
+
- **`apply_shape_profile` was the prime suspect and is fine.** 700 fuzzed annotations found no crash and no misalignment. Don't spend time there. We still don't copy it: collapsing to equivalence classes first means a column has one geometry, so `Design.model_spans` (the **pre-mask** span, recorded at build time because masking destroys it) makes the mapping direct instead of needing the reference's averaged "expected range" plus `omit` bookkeeping.
|
|
156
|
+
- **`span=0.4` is wrong even after the bugs are fixed.** A pausing peak maps to under 1/100 of the axis; span 0.4 gives bandwidth ~0.2. Measured against the unsmoothed cloud (empirical 9.29×): 0.4 → 2.38×, 0.05 → 5.59×, 0.02 → 8.47×. `ShapeConfig.span` defaults to **0.05**, with `min_bandwidth` floored at two head bins.
|
|
157
|
+
- **A shape profile and TSS masking compete for the same bins.** Masking the first 10 kb of a 40 kb gene removes **89%** of the grading (14.30× → 1.60×). Report `shape.applied_dynamic_range(design)`, **not** `ShapeProfile.dynamic_range` — `build-design` prints it and warns below 1.2×.
|
|
158
|
+
- Per-gene normalization is the **mean**, not min–max: min–max maps a silent gene to a constant 1.0 (the reference's `- 1e-6` guard) and lets the pause spike set the scale for the body. Silent genes are dropped and counted in `ShapeProfile.rejected`.
|
|
159
|
+
- `min_length` defaults to `head_bases + tail_bases` and is validated against it. The reference defaults `min_transcript_length=8e3` below `head+tail=15e3`, so with *its* defaults every 8–15 kb gene contributes no body positions at all.
|
|
160
|
+
- Reshaping yields a new design digest and sets `Design.shape_digest`; applying twice raises.
|
|
161
|
+
|
|
162
|
+
## R parity workflow
|
|
163
|
+
|
|
164
|
+
Golden values are frozen R output committed under `tests/fixtures/parity/`. Inputs are **generated by us** from `tests/fixtures/scenarios.py` and never copied from the R package, because its `LICENSE.md` (MIT) and `DESCRIPTION` (GPL ≥2) disagree about redistribution. R program output is not a derivative work, so committing it is fine; committing their `inst/extdata` fixtures would not be.
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
uv run python -m tests.fixtures.generate --output build/parity-inputs
|
|
168
|
+
# then, inside the DENR container (the frozen reference environment):
|
|
169
|
+
singularity exec denr_latest.sif Rscript scripts/export_r_golden.R --inputs build/parity-inputs --output tests/fixtures/parity --commit 09d6cde5ef7ad1a6eb140c6e25dbae91e2a9a819
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A local R needs GenomicRanges, GenomicFeatures, rtracklayer and AnnotationDbi, none of which are installed on this machine — hence the container. See `tests/fixtures/parity/README.md`.
|
|
173
|
+
|
|
174
|
+
`tests/test_parity_harness.py` exercises the comparison machinery against a golden generated from *our own* output. That validates the plumbing (column-major reshape, locus matching by geometry, 0-based mask indices) and proves the comparisons can actually fail; it says nothing about R agreement, and `test_synthetic_goldens_are_never_committed` stops such a fixture from ever masquerading as parity.
|
|
175
|
+
|
|
176
|
+
## Design decisions already made (from the plan)
|
|
177
|
+
|
|
178
|
+
These are deliberate departures from the R package; do not "restore" the R behavior without discussion.
|
|
179
|
+
|
|
180
|
+
- **Reimplementation, not a translation.** Compatibility with the *published method* is required; legacy output conventions and silent-failure behavior are not.
|
|
181
|
+
- **Identifiability is a first-class output.** Unique pre-RNA models, not annotation transcripts, are the primary estimands. Exactly duplicate design columns are collapsed before fitting, with a many-to-one transcript→model map preserved and `equivalence_class_size` reported. Never split an equivalence class's abundance across its transcripts in primary output.
|
|
182
|
+
- **Failures never become zeros.** Every sample×locus fit returns an explicit status (`ok`, `all_zero`, `insufficient_bins`, `rank_deficient`, `nonconverged`, `numerical_error`, …). Invalid loci get `NaN` plus a reason. Solver convergence info, iteration count, gradient norm, and objective are retained — the R version discards them.
|
|
183
|
+
- **Coordinates:** 0-based half-open internally and in BED-like output; GTF/GFF converted exactly once at the parser boundary; strand must be `+`/`-`; contig renaming explicit and recorded, never guessed.
|
|
184
|
+
- **Separation of stages:** `build-design` / `count` / `fit` are distinct CLI commands so annotation geometry is computed once and reused across samples and cohorts. Any change to annotation, bin size, masking, TSS evidence, or shape profile must yield a new design digest.
|
|
185
|
+
- **Multi-sample by construction:** one reusable `Design` against many `SampleCounts`; the R object holds one sample at a time.
|
|
186
|
+
- **Fitting and normalization are separate operations.** `abundance_raw` always survives. Do not call a trimmed mean of fitted abundances "TMM" (the R package does; the plan explicitly forbids it).
|
|
187
|
+
- **Likelihoods, staged:** legacy identity/log least-squares parity first, then nonnegative Poisson (+ quasi-Poisson errors), then ridge/lasso/elastic-net/group penalties. NB2 was built, evaluated against Gate D, and removed. Count likelihoods require count-equivalent input — reject normalized/smoothed BigWigs and `mean` summaries. `counts.COUNT_LIKELIHOODS` / `counts.require_count_equivalent` are the **single** definition and check; there were once two copies and the public one silently lacked `quasi_poisson`.
|
|
188
|
+
- **Tabular layer is Polars, not pandas.** DataFrames live only at the boundaries (annotation parsing, result writing); data stays in NumPy/SciPy through fitting. Polars writes the Parquet outputs directly with `write_parquet(metadata=...)` for `schema_version`/digests. Pandas is not used anywhere.
|
|
189
|
+
- **`read_binned` is the single BigWig read path, and it normalizes sign and length itself.** Minus-strand BigWigs are conventionally negative; strand is carried on `SignalFile` and never inferred from sign, so a signed value is only a way to get a wrong answer. This lived in `count_sample` alone until `shape.py` read through `read_binned` directly and got neither correction — **487 of 487 minus-strand genes silently dropped**, invisible because every fixture wrote positive values on both strands (the same blind spot that hides the reference's strand bugs from *its* tests). Don't add a second reader.
|
|
190
|
+
- **All BigWig access goes through `figwig`** (replaced `pybigtools`; plan §7 "BigWig aggregation"). figwig reads exact per-base float32 and has no zoom-level path; `read_binned` bins in NumPy. **Read with `missing=np.nan, oob=np.nan`** — the default `missing=0.0` makes an uncovered base indistinguishable from a zero-valued interval, and `mean`/`min`/`max`/`std` are over *covered* bases only (a zero interval counts as covered; empty bin → 0; `std` is ddof=1, 0 below two bases; bin `i` = `[start+⌊i·w/n⌋, start+⌊(i+1)·w/n⌋)`). These are the pybigtools-era semantics, verified on 5,000 random windows against a per-base oracle. figwig only *warns* on an absent contig and returns zeros, so `read_binned` checks `chrom_sizes` and raises `KeyError`. Reuse one `BigWigReader` per file — it parses the data index once per reader, so a per-call path re-reads it.
|
|
191
|
+
- **figwig is only fast when batched — never read loci one call at a time in a hot path.** One call per locus left its threads idle and was slower than pybigtools (1.13 s vs 1.01 s). `count_sample` uses `read_loci_binned`: loci cut into `TILE_BINS`=16-bin tiles, read in calls capped at `MAX_CHUNK_BYTES`=64 MiB, binned in one vectorized pass split across `n_jobs` threads (single-threaded binning cost as much as the read). ~145 ms/sample steady state vs ~330 ms for pybigtools on the dense benchmark (2.3×); ~240 ms one-time numba warm-up; +110 MB RSS for the chunk buffers. Contig-overrunning windows keep the clipped `read_binned` path. `dienr count --threads N` (default 8, must be ≥1) sets `n_jobs`; threads parallelize within a sample, samples run sequentially. `read_binned` stays the single-window entry point (`shape.py`).
|
|
192
|
+
- **Three pybigtools binning bugs were deliberately not reproduced**, found during the switch: a bin straddling the contig end came back 0 (dropping in-bounds signal); non-divisible windows counted covered bases with floating-point edges (26 vs 27 in a `mean` denominator); `std` used a one-pass formula (9e-8 for a constant bin). None affected parity or the benchmark.
|
|
193
|
+
- **No genomics interval library in core.** Locus grouping is a sort + cumulative-max sweep in plain Polars (5 lines), verified bit-identical to `polars-bio.merge` at 200k transcripts / 52k loci for `min_dist` 0/250/5000. GTF parsing is `scan_csv(separator="\t", comment_prefix="#")` + two `str.extract` calls — faster than `scan_gtf` (32ms vs 53ms/50k rows) and coordinate-identical. Masking is bin-index arithmetic, not interval joins. Reconsider `polars-bio` only for Phase 4 TSS-evidence overlap joins if `join_where` is too slow.
|
|
194
|
+
- **Single-model loci take a fast path**, because profiling showed ~91% of a median realistic locus (64 bins, 1 model) was L-BFGS-B scaffolding, not arithmetic. Identity has a closed form; log uses a bounded scalar search. Any change there must keep `test_single_model_fast_path_matches_general_optimizer` passing. **Poisson has one too** (`poisson._fit_single_model`): `a = Σy / Σ s·m` over usable bins is the exact MLE for *any* nonnegative column (run-on tails included) and equals `moment_initialization`'s start, so L-BFGS-B was starting at the answer. Taken only with no penalty, no background, no supplied start, and when the `MU_FLOOR` guard can't bind; otherwise the general path. Bit-identical abundances, 2.1× per locus at 64 bins, 1.45× at 400 — what's left is `fit_locus`'s input validation.
|
|
195
|
+
- **Numba was evaluated and rejected** — see plan §14.1. It cannot beat BLAS on large loci (1.09×) and the small-loci win it does offer (2.5–3.3×) addresses the 9% of runtime that is arithmetic, not the 91% that was optimizer overhead.
|
|
196
|
+
- **Count likelihoods need `summary="sum"`, but the parity default is `"mean"`.** `fit` raises rather than fitting a per-base density with a count likelihood — this is the most likely way to misuse the Poisson solver, since counts built with defaults look fine.
|
|
197
|
+
- **The μ floor (`_glm.MU_FLOOR`) cannot move an interior optimum but does bound a boundary gradient that is truly infinite.** Consequence: **an L1 penalty cannot drive a model to exactly zero without a background**, because no finite penalty outweighs a divergent likelihood. Set `Exposure(background=...)` when sparsity is the goal.
|
|
198
|
+
- **`inactive` models must be excluded from the support test, not just bounded.** Pinning a model leaves bins only it reached with no free model; treating them as usable drove μ→0 against nonzero counts and failed the whole locus. `usable_bins` takes `pinned` for this.
|
|
199
|
+
- **Use `likelihood="quasi_poisson"` for overdispersed counts.** Same abundances as `"poisson"`, errors scaled by `sqrt(φ)` with φ pooled over every locus of every sample (a second pass after fitting). `abundance_standard_error` is **NaN where a Wald error is undefined** — a model at the nonnegativity boundary, or a singular information matrix — because a large finite number would invite an interval that does not exist. Legacy reports null: a sum of squares implies no variance model.
|
|
200
|
+
- **`DispersionEstimate.is_overdispersed()` needs `z` standard errors of headroom**, not a bare `> 1`. The Pearson statistic's SE is `sqrt(2/df)` under the null, so a knife-edge test fires on ~half of Poisson datasets — it did, at 1.0215.
|
|
201
|
+
- **The negative-binomial (NB2) solver was removed** — don't re-add it without new evidence against plan Gate D. It cannot change a point estimate on a binary design at any dispersion (identity-link score weights residuals by `1/V(μ)`, constant on a 0/1 column's support, so MSE, Poisson and NB2 agreed to `0.000e+00` even at φ=3). Its only value was calibrated uncertainty, and **quasi-Poisson matched NB2's standard errors to within 1–4%** with one scalar. It was also not outlier-robust (the score `Σ M(μ−y)/V(μ)` has unbounded influence; bounded influence needs M-estimation), not globally convex with the identity link (zero-count bins are concave), and needed an externally pooled dispersion. The one case it could matter — a graded design — is a shape profile applied to `M`, which §18.8 advises against.
|
|
202
|
+
- **Masking has two forms.** `mask_tss`/`mask_pas` take **bases** along the direction of transcription and are preferred; `mask_start_bins`/`mask_end_bins` take bin counts and exist for R parity. Mutually exclusive. Bin counts make the masked span depend on `bin_size` — 4 bins is 400bp at 100 and 2000bp at 500.
|
|
203
|
+
- **The mask is a union over every transcript in a locus, and has to be.** Masking says "signal in this bin is untrustworthy", a property of the bin not of a model; an initiation spike contaminates the bin for every transcript through it. Cost is real and now reported: the reference's ±4 bins discards **22.7% of all bins** on the benchmark. See `Design.masked_fraction`, `heavily_masked_loci`, and `locus_qc.masked_bin_fraction`.
|
|
204
|
+
- **`locus_qc.total_observed` is fit-bin signal, not coverage.** Masked bins are zeroed in place, so `total_observed_all_bins` (recorded pre-mask in `SampleCounts.totals_all_bins`) is the one to use for coverage.
|
|
205
|
+
- **The reference's "counts" are mean per-base coverage, not counts.** R's `add_data` hardcodes `summary_operation <- "mean"`, so DENR abundances are polymerase *density*. `count_sample` therefore defaults to `summary="mean"`; a `sum` summary gave exactly `bin_size` times R's values. Poisson/NB will need `"sum"`, and `require_count_equivalent` rejects the `mean` + count-likelihood combination.
|
|
206
|
+
- **R aborts on an unknown contig; we do not.** `assert_chromosome_exists` kills an entire DENR run over one bad contig name. We record `missing_contig` for that locus and continue. The `absent_contig` scenario documents this and is excluded from parity coverage since R cannot produce a golden for it.
|
|
207
|
+
- **Fixture BigWigs go through `tests/fixtures/bigwig.write_intervals`**, which writes with `missing=nan` so explicit `0.0` intervals stay stored (they enter a `mean` denominator). figwig keeps every declared contig in the header and can write an empty file, so the zero-valued placeholder bases the pybigtools writer forced are gone — the header is still what separates `missing_contig` from `all_zero`. figwig's writer requires header order and non-overlapping items; the helper sorts.
|
|
208
|
+
- **Shape profiles are implemented, not a non-goal.** `Design.model_spans` holds the **pre-mask** occupied span per model, because masking destroys it and the profile needs a position along the transcript. Any new per-locus accessor must index, never scan (`model_span` follows `mask`).
|
|
209
|
+
- **Non-goals for v1:** Gviz plotting, TxDb download/caching, the legacy TSS CNN, de novo transcript discovery.
|
|
210
|
+
|
|
211
|
+
Target module layout, phase breakdown, output schemas (`model_abundance.parquet`, `transcript_model_map.parquet`, `locus_qc.parquet`, `normalization_factors.parquet`), and decision gates are all specified in the plan — follow it rather than re-deriving.
|
|
212
|
+
|
|
213
|
+
## Licensing caveat
|
|
214
|
+
|
|
215
|
+
`dienr` is MIT (© 2026 Adam Youlin He). The R reference has **conflicting** metadata: `_reference/DENR/LICENSE.md` says MIT, `_reference/DENR/DESCRIPTION` says GPL (≥2). Until that is resolved with the upstream maintainers, implement from the paper, the README methodology section, and observed fixture behavior — do not copy or line-by-line adapt R source into this repository.
|