Skip to content

feat: split methylate from simulate; methylation truth via VCF MT/MB#11

Merged
tfenne merged 8 commits into
mainfrom
nh/bisulfite-conversion
Jun 13, 2026
Merged

feat: split methylate from simulate; methylation truth via VCF MT/MB#11
tfenne merged 8 commits into
mainfrom
nh/bisulfite-conversion

Conversation

@nh13

@nh13 nh13 commented May 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Splits methylation simulation into two composable commands and moves the methylation truth into the input VCF:

  • holodeck methylate (new): annotates a VCF (or the bare reference) with per-haplotype, per-strand CpG methylation truth in new MT/MB FORMAT fields. Optional --bedgraph writes a closed-form, MethylDackel-format population-fraction bedGraph.
  • holodeck simulate (refactored): reads MT/MB from the input VCF and applies em-seq or TAPS chemistry. --methylation-rate is removed — methylation state is now an inspectable artifact rather than a transient simulation parameter. The full Bismark-compatible golden-BAM tag set (XG/XR/XM/YM/NM/MD/YS) is preserved.

The split separates biology (which CpGs are methylated) from chemistry (em-seq vs TAPS conversion at some efficiency), so a genome can be methylated once and re-sequenced under different chemistries.

Architecture

Four commits, organized by layer:

  1. feat(vcf): add methylation classifier and MT/MB VCF I/O — per-haplotype CpG ownership classifier (Standalone vs OnVariant with upstream-wins for adjacent variants), writer + reader for MT/MB-bearing VCFs, all 13 documented edge cases plus closed-loop fuzz round-trip.
  2. feat(methylate): add subcommand for producing methylation-annotated VCFsholodeck methylate end-to-end with Bernoulli draws, optional population-fraction bedGraph.
  3. refactor(simulate): load methylation truth from VCF MT/MB; drop --methylation-rate — simulate now sources truth exclusively from the input VCF's MT/MB fields. Adds a validation matrix governing the (VCF has MT/MB, --methylation-mode set) cells.
  4. docs(readme): document the methylate + simulate two-command flow — README rewrite covering the schema, validation matrix, bedGraph comparison, and a complete worked example.

Validation matrix (simulate)

Input VCF has MT/MB? --methylation-mode set? Behavior
yes yes Run methylation chemistry against the embedded truth
yes no Warn once and produce variants-only output
no yes Hard error: run \holodeck methylate` first`
no no Variants-only output, byte-identical to pre-methylation baseline

MT/MB schema

##FORMAT=<ID=MT,Number=.,Type=String,Description="Methylation, top strand">
##FORMAT=<ID=MB,Number=.,Type=String,Description="Methylation, bottom strand">

# Standalone methylation-only record (REF=C ALT=. , no GT, one 0/1 bit per hap):
chr1  100200  .  C  .  .  .  .  MT:MB  1|0:0|1

# Variant record (GT:MT:MB, per-hap bitstring or '.'):
chr1  100500  .  T  ACGTACG  .  .  .  GT:MT:MB  1|0:11|.:11|.

CpGs straddling a variant boundary are owned by the variant. When two adjacent variants jointly form a CpG, the upstream variant wins (deterministic tiebreaker). Variants must be phased; overlapping REF spans on the same haplotype are rejected upfront with ClassifyError.

Test plan

358 tests passing on cargo ci-fmt && cargo ci-lint && cargo ci-test (baseline main = 248; PR #11's original commit added the chemistry tests; this refactor adds the classifier + writer + reader + matrix + bedGraph tests and migrates the existing chemistry tests to drive methylation truth via methylate).

  • Classifier: 17 tests covering all 13 documented edge cases (CpG straddles, destroys, re-creates, multi-allelic, homozygous, hemizygous, plus the rejections for unphased GT and overlapping variants).
  • Writer / reader: standalone + variant record unit tests, round-trip tests, ReadError variant tests, header_has_mt_mb probe tests, plus two closed-loop fuzz round-trips (10 kb random reference, then with phased SNPs).
  • methylate: skeleton smoke test, MT/MB VCF output, end-to-end pipeline (methylatesimulate → golden BAM YM:Z tag verification), bedGraph, --methylation-rate range validation, --seed determinism, --vcf variants-input path.
  • simulate: four validation-matrix cells, --cpg-truth-bedgraph integration, byte-identity across same-seed runs in the no-methylation cell, plus all existing chemistry / Bismark-tag tests migrated to use the methylate_to_vcf test helper.

Test plan checklist

  • All 358 tests pass (cargo ci-test)
  • cargo ci-fmt && cargo ci-lint clean (clippy pedantic)
  • End-to-end smoke test: small reference → methylatesimulate --methylation-mode em-seq --golden-bam produces a valid Bismark-compatible BAM with XM/YM reflecting the input methylation rate
  • README documents the two-command flow, schema, validation matrix, and a worked example

Summary by CodeRabbit

  • New Features

    • End-to-end CpG methylation: new methylate command, methylation-aware simulate (EM‑seq/TAPS), golden‑BAM methylation tags, per-template CpG-truth bedGraph and population‑fraction bedGraph outputs.
  • CLI

    • New methylate subcommand; simulate adds methylation flags (mode, conversion/failure rates, cpg-truth bedgraph) and stricter flag validation; seed description extended when methylation is enabled.
  • Documentation

    • README/CLI docs expanded with workflow, examples, option matrix, tag semantics, and limitations.
  • Tests

    • Extensive unit/integration tests covering chemistry, tags, VCF/bedGraph, determinism, and validations.
  • Chore

    • Added a bit-level dependency for methylation data handling.

@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a methylation feature: a new methylate CLI that emits MT/MB-annotated VCFs and optional bedGraph, methylation-aware simulate wiring and chemistry, per-haplotype methylation bitmaps and conversion logic, VCF MT/MB I/O and classifier, golden-BAM methylation tags, bedGraph writers, haplotype-coordinate plumbing, and extensive tests/docs.

Changes

Methylation feature

Layer / File(s) Summary
Dependency & docs
Cargo.toml, README.md
Adds bitvec dependency and documents methylate→simulate workflow, CLI options, chemistry semantics, golden‑BAM tags, and bedGraph semantics.
methylate command & CLI wiring
src/commands/methylate.rs, src/commands/mod.rs, src/main.rs
Implements holodeck methylate CLI, argument validation, deterministic seeding, BGZF VCF output and optional bedGraph, and command registration/documentation.
VCF parsing API
src/vcf/mod.rs
Introduces ParsedVariants and a single-pass parse_variants_by_contig to partition sample ALT-bearing variants and resolve sample ploidy.
VCF MT/MB I/O & classifier
src/vcf/methylation.rs
Adds phased-only CpG placement classifier, MT/MB VCF writer/reader, header probe and single-pass partitioning utilities, and extensive reader/writer tests.
Haplotype & fragment coordinate plumbing
src/haplotype.rs, src/fragment.rs
Adds haplotype coordinate index, hap_position_for, extract_fragment returns hap_start, and Fragment.hap_start to maintain haplotype-aligned offsets; tests updated.
Core methylation model
src/meth.rs
Adds MethylationTable, ContigMethylation, MethylationMode, MethylationConfig, apply_methylation_conversion, conversion-failure modeling, conversion-type/annotation types, and unit tests.
Methylation call tag computation
src/methylation_tags.rs
Implements CallTags, compute_call_tags, and populate_pair_call_tags to generate XM/YM/NM/MD and related tests.
Read generation integration
src/read.rs
Refactors generate_read_pair to accept MethylationConfig, applies fragment-scale chemistry once, captures pre-conversion bases for golden BAM, and populates ReadPair.methylation.
Golden BAM, CpG truth & methylation bedGraph writers
src/output/golden_bam.rs, src/output/cpg_truth.rs, src/output/methylation_bedgraph.rs, src/output/mod.rs
Emits methylation-related BAM tags (XG, XR, YS, cf:i, optional XM/YM/NM/MD), implements CpG truth tallying and bedGraph writer, population-fraction bedGraph writer, and exports.
simulate CLI & orchestration
src/commands/simulate.rs
Adds --methylation-mode, --methylation-conversion-rate, --methylation-failure-rate, --cpg-truth-bedgraph; validates flag combos; parses MT/MB VCF once; threads CpG tally state; seed includes methylation context; per-contig simulate signature updated; unit tests added.
Tests & helpers
tests/*, tests/helpers/mod.rs
Adds integration/unit tests across methylate→simulate pipeline, golden-BAM/bedGraph validations, classifier edge-cases, determinism checks, and test helpers.

Sequence Diagram(s)

sequenceDiagram
  participant CLI as holodeck CLI
  participant Methylate as methylate subcommand
  participant VCF as MT/MB VCF (BGZF)
  participant Simulate as simulate subcommand
  participant ReadGen as read generation (generate_read_pair)
  participant BAM as Golden BAM writer
  CLI->>Methylate: run methylate --reference --methylation-model --output
  Methylate->>VCF: write MT/MB records (per-haplotype)
  CLI->>Simulate: run simulate --methylation-mode --vcf MT/MB.vcf
  Simulate->>VCF: parse MT/MB once, build ContigMethylation
  Simulate->>ReadGen: generate_read_pair(..., methylation_config)
  ReadGen->>ReadGen: apply_methylation_conversion (fragment-scale)
  ReadGen->>BAM: provide methylation annotations
  BAM->>BAM: emit XG/XR/YS and optional XM/YM/NM/MD tags
  Simulate->>Simulate: record per-mate CpG tallies
  Simulate->>File: write cpg-truth bedGraph
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

"A rabbit hopped through code and seeds,
Scattered CpGs among the reads,
It penned MT/MB with care,
Simulated chemistries there,
Hopped off—happy, tail of reeds."

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

@nh13
nh13 requested a review from tfenne May 3, 2026 01:26

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
README.md (1)

155-155: ⚡ Quick win

Consider clarifying "reference orientation" for the YS:Z tag.

The phrase "the pre-conversion read sequence in reference orientation" could be ambiguous. For a read derived from the bottom strand, does "reference orientation" mean:

  • The sequence as it would appear in the forward reference strand?
  • The sequence before reverse-complementing for BAM storage?
  • Something else?

Consider adding a brief clarification, such as: "in reference (forward-strand) orientation" or "oriented to the positive strand" to remove ambiguity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` at line 155, Clarify the ambiguous phrase describing the YS:Z tag
by specifying what "reference orientation" means: update the README line for
`YS:Z` to say it is the pre-conversion read sequence oriented to the forward
(positive) reference strand (e.g., "in reference (forward-strand) orientation"
or "oriented to the positive strand") so readers understand how `YS:Z` relates
to `SEQ` for bottom-strand-derived reads; keep the note that no bisulfite
aligner uses this tag and that it exists to allow downstream evaluators to diff
`SEQ` against `YS` base-for-base to recover ground-truth chemistry events.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/simulate.rs`:
- Around line 204-210: The validations for methylation_rate and
methylation_conversion_rate should only run when a methylation mode is enabled;
modify the logic around the existing checks (methylation_rate,
methylation_conversion_rate) to first test methylation_mode (e.g., if let
Some(_) = self.methylation_mode or self.methylation_mode.is_some()) and perform
the finite/0..=1.0 validation only in that branch, otherwise reject or warn when
the user supplied those flags without a mode (bail with a clear message asking
for --methylation-mode) so the flags aren’t silently ignored.
- Around line 521-544: The code builds a single methylation table from the
normalized reference (methylation / methylation_config using derive_seed,
main_seed, contig_name and self.methylation_mode) but later samples reads from
per-haplotype sequences (haplotypes[hap_idx]), so CpG sites created/destroyed by
variants will be mis-modeled; either make methylation table construction
haplotype-aware (build a table per haplotype using the haplotype sequence used
for reads) or, if you can't, add an explicit guard that rejects running with
both VCF-driven haplotypes and methylation enabled: detect the combination
(self.methylation_mode.is_some() && vcf/VCF-provided haplotypes in this
command), and return an error/exit with a clear message instead of silently
proceeding; update the codepaths that create methylation/methylation_config (the
block that calls crate::meth::MethylationTable::from_reference and uses
derive_seed/main_seed/contig_name) to implement this check.

---

Nitpick comments:
In `@README.md`:
- Line 155: Clarify the ambiguous phrase describing the YS:Z tag by specifying
what "reference orientation" means: update the README line for `YS:Z` to say it
is the pre-conversion read sequence oriented to the forward (positive) reference
strand (e.g., "in reference (forward-strand) orientation" or "oriented to the
positive strand") so readers understand how `YS:Z` relates to `SEQ` for
bottom-strand-derived reads; keep the note that no bisulfite aligner uses this
tag and that it exists to allow downstream evaluators to diff `SEQ` against `YS`
base-for-base to recover ground-truth chemistry events.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f0879437-524f-43ea-85e6-5ca3af1a6437

📥 Commits

Reviewing files that changed from the base of the PR and between 73fd0ab and d1c1a5a.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • Cargo.toml
  • README.md
  • src/commands/simulate.rs
  • src/lib.rs
  • src/meth.rs
  • src/output/golden_bam.rs
  • src/read.rs
  • tests/helpers/mod.rs
  • tests/test_simulate_meth.rs

Comment thread src/commands/simulate.rs Outdated
Comment thread src/commands/simulate.rs Outdated
@nh13
nh13 force-pushed the nh/bisulfite-conversion branch from d1c1a5a to 460d211 Compare May 3, 2026 06:08

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/haplotype.rs`:
- Around line 185-195: hap_position_for currently does a full linear scan over
self.variant_data for every call, causing O(fragments×variants) cost; fix by
precomputing a cumulative delta index and using a binary search instead of
scanning. Add a cached vector (e.g. cumulative_deltas or index_points)
computed/updated whenever variant_data changes that stores for each variant the
ref_end (var.ref_pos+var.ref_len) and the running sum of (alt_bases.len() -
ref_len) as i64, then change hap_position_for to binary_search the largest
ref_end <= ref_pos and read the precomputed cumulative delta to compute hp;
reference the methods/fields variant_data and hap_position_for (and update
construction/update points where variant_data is mutated, e.g. where
extract_fragment or hap_start logic may rely on variant_data) so the cache stays
in sync.
- Around line 127-129: The hap_start is computed using the original ref_start
which is wrong when the pre-loop advances ref_pos past a deletion; change the
computation to call self.hap_position_for using the post-skip coordinate
(ref_pos) instead of ref_start so downstream methylation lookups align with the
actual first surviving reference base—update the hap_start assignment to use
ref_pos and keep references to hap_position_for, ref_start, ref_pos and the
pre-loop behavior in mind when making the change.

In `@src/meth.rs`:
- Around line 96-113: The current cap calculation (let cap =
reference.len().saturating_add(1)) can truncate haplotypes that contain >1 base
of net insertion because extract_fragment(reference, 0, cap) limits emitted
haplotype bases; change the cap so it will not artificially truncate inserted
sequence (e.g. pass a very large cap or usize::MAX) so extract_fragment can walk
until it consumes the whole reference; update the code around cap,
haplotype.extract_fragment, and any related comments so hap_bases and _hap_start
reflect the full materialized haplotype rather than being truncated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6785f185-64c8-4955-b973-31e4dd83282e

📥 Commits

Reviewing files that changed from the base of the PR and between d1c1a5a and 460d211.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • Cargo.toml
  • README.md
  • src/commands/simulate.rs
  • src/fragment.rs
  • src/haplotype.rs
  • src/lib.rs
  • src/meth.rs
  • src/output/golden_bam.rs
  • src/read.rs
  • tests/helpers/mod.rs
  • tests/test_simulate_meth.rs
✅ Files skipped from review due to trivial changes (3)
  • src/lib.rs
  • Cargo.toml
  • README.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/read.rs
  • tests/helpers/mod.rs
  • src/output/golden_bam.rs

Comment thread src/haplotype.rs Outdated
Comment thread src/haplotype.rs Outdated
Comment thread src/meth.rs Outdated
@nh13
nh13 force-pushed the nh/bisulfite-conversion branch 2 times, most recently from ae0f48e to 4576552 Compare May 4, 2026 05:00

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/commands/simulate.rs`:
- Around line 212-232: The current guard uses float equality on
self.methylation_rate and self.methylation_conversion_rate (via rate_overridden
and conversion_overridden) so explicitly passing “1.0” bypasses validation;
change the logic to check whether the flags were supplied rather than their
numeric value by introducing presence booleans (e.g. track flag presence from
the CLI parser or change the struct fields to Option<f64> and use is_some()) for
methylation_rate and methylation_conversion_rate and then require
self.methylation_mode.is_some() if either presence flag is true; update the
rejection branch that bails with "--methylation-rate and
--methylation-conversion-rate require --methylation-mode" and add regression
tests covering explicitly supplied 1.0 for both methylation_rate and
methylation_conversion_rate to ensure the new presence-based check catches them.
- Around line 128-137: The help text for the methylation probability incorrectly
states methylation is drawn "in the reference" once per genomic position; update
the documentation in simulate.rs to explain methylation is sampled per
haplotype/allele (not just the reference) to match the implementation that uses
ContigMethylation::from_haplotypes(...). Edit the comment block describing the
flag (the paragraph starting "Probability that any CpG-context cytosine...") to
say the methylation state is drawn separately for each haplotype/allele at each
genomic position (deterministic from --seed), so allele-specific/variant-aware
methylation and hemimethylation are correctly communicated to CLI users.

In `@tests/test_simulate_meth.rs`:
- Around line 154-187: The empirical-band test in
test_em_seq_partial_conversion_rate is flaky because it relies on compute_seed
via the temp FASTA path; explicitly pass a fixed seed to run_simulate to
stabilize the RNG (e.g., add the "--seed" and "42" arguments to the run_simulate
args vector used in test_em_seq_partial_conversion_rate) so the derived band
matches the comment's seed; update the test invocation in the
test_em_seq_partial_conversion_rate function accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 019b0da5-6612-444d-8c5c-c01253dab386

📥 Commits

Reviewing files that changed from the base of the PR and between ae0f48e and 4576552.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • Cargo.toml
  • README.md
  • src/commands/simulate.rs
  • src/fragment.rs
  • src/haplotype.rs
  • src/lib.rs
  • src/meth.rs
  • src/output/golden_bam.rs
  • src/read.rs
  • tests/helpers/mod.rs
  • tests/test_simulate_meth.rs
✅ Files skipped from review due to trivial changes (2)
  • Cargo.toml
  • src/fragment.rs
🚧 Files skipped from review as they are similar to previous changes (6)
  • src/lib.rs
  • tests/helpers/mod.rs
  • src/output/golden_bam.rs
  • README.md
  • src/meth.rs
  • src/haplotype.rs

Comment thread src/commands/simulate.rs Outdated
Comment thread src/commands/simulate.rs Outdated
Comment thread tests/test_simulate_meth.rs
@nh13
nh13 force-pushed the nh/bisulfite-conversion branch from 4576552 to 37592f0 Compare May 5, 2026 03:19

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
src/commands/simulate.rs (2)

128-137: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document the per-haplotype sampling model.

This help text still says methylation is drawn once “in the reference” per genomic position, but the implementation now samples per haplotype × strand CpG via ContigMethylation::from_haplotypes(...). That hides allele-specific methylation and variant-created/destroyed CpGs from CLI users.

✏️ Suggested wording
-    /// Probability that any CpG-context cytosine in the reference is
-    /// methylated. Drawn once per genomic position at simulation start
-    /// (deterministic from `--seed`). The two C's of each CpG site are
-    /// drawn independently per strand, so hemimethylation is possible.
+    /// Probability that any CpG-context cytosine on a haplotype is
+    /// methylated. Drawn once per haplotype × strand CpG site at
+    /// simulation start (deterministic from `--seed`), so allele-specific
+    /// methylation and variant-created/destroyed CpGs are modeled. The two
+    /// C's of each CpG site are drawn independently per strand, so
+    /// hemimethylation is possible.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/simulate.rs` around lines 128 - 137, Update the long help text
for the methylation probability flag to document that methylation is sampled per
haplotype × strand (not once "in the reference") by the
ContigMethylation::from_haplotypes model, so allele-specific methylation and
CpGs created/destroyed by variants are captured; change any wording that says
"drawn once per genomic position in the reference" to explicitly say "sampled
per haplotype and strand (via ContigMethylation::from_haplotypes), deterministic
from --seed" and retain the notes about hemimethylation, non-CpG behavior,
default value, and examples.

224-244: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject explicitly supplied default-valued methylation flags too.

rate_overridden and conversion_overridden only detect numeric changes, so --methylation-rate 1.0 and --methylation-conversion-rate 1.0 still slip through without --methylation-mode. Those invocations silently fall back to the unmethylated path even though this PR advertises that supplying either rate flag without a mode is invalid. Please track flag presence instead of comparing against 1.0, and add regressions for the explicit-1.0 cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/simulate.rs` around lines 224 - 244,
rate_overridden/conversion_overridden currently compare numeric values to 1.0
and therefore miss cases where the user explicitly passed “--methylation-rate
1.0” or “--methylation-conversion-rate 1.0”; change the flag tracking so you
detect presence instead of value: make the parsed inputs for methylation_rate
and methylation_conversion_rate carry presence (e.g. Option<f64> or a companion
bool like methylation_rate_provided/methylation_conversion_rate_provided set by
the CLI parser), then replace the comparisons with checks for presence (e.g. let
rate_provided = self.methylation_rate.is_some() or
self.methylation_rate_provided; let conversion_provided = ... ) and keep the
existing guard that if self.methylation_mode.is_none() && (rate_provided ||
conversion_provided) bail(...). Also add unit/regression tests that call the CLI
with explicit “1.0” for each rate flag (and combinations) to verify they are
rejected when methylation_mode is absent.
tests/test_simulate_meth.rs (1)

154-187: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Seed this empirical-band test explicitly.

The comment says this band was derived from seed 42, but the command omits --seed. Because compute_seed() hashes the temp FASTA path, the effective RNG varies between runs and can make this assertion flaky.

🔧 Minimal fix
         "--methylation-conversion-rate",
         "0.5",
+        "--seed",
+        "42",
         "--threads",
         "1",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_simulate_meth.rs` around lines 154 - 187, The test
test_em_seq_partial_conversion_rate is flaky because the run_simulate call omits
an explicit RNG seed (compute_seed() hashes the temp FASTA path), so add a
deterministic seed flag (e.g., "--seed", "42") to the run_simulate argument list
used in this test to match the empirical band derived with
SmallRng::seed_from_u64(42); update the run_simulate invocation in
test_em_seq_partial_conversion_rate to include the seed so the RNG output is
stable across runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/commands/simulate.rs`:
- Around line 128-137: Update the long help text for the methylation probability
flag to document that methylation is sampled per haplotype × strand (not once
"in the reference") by the ContigMethylation::from_haplotypes model, so
allele-specific methylation and CpGs created/destroyed by variants are captured;
change any wording that says "drawn once per genomic position in the reference"
to explicitly say "sampled per haplotype and strand (via
ContigMethylation::from_haplotypes), deterministic from --seed" and retain the
notes about hemimethylation, non-CpG behavior, default value, and examples.
- Around line 224-244: rate_overridden/conversion_overridden currently compare
numeric values to 1.0 and therefore miss cases where the user explicitly passed
“--methylation-rate 1.0” or “--methylation-conversion-rate 1.0”; change the flag
tracking so you detect presence instead of value: make the parsed inputs for
methylation_rate and methylation_conversion_rate carry presence (e.g.
Option<f64> or a companion bool like
methylation_rate_provided/methylation_conversion_rate_provided set by the CLI
parser), then replace the comparisons with checks for presence (e.g. let
rate_provided = self.methylation_rate.is_some() or
self.methylation_rate_provided; let conversion_provided = ... ) and keep the
existing guard that if self.methylation_mode.is_none() && (rate_provided ||
conversion_provided) bail(...). Also add unit/regression tests that call the CLI
with explicit “1.0” for each rate flag (and combinations) to verify they are
rejected when methylation_mode is absent.

In `@tests/test_simulate_meth.rs`:
- Around line 154-187: The test test_em_seq_partial_conversion_rate is flaky
because the run_simulate call omits an explicit RNG seed (compute_seed() hashes
the temp FASTA path), so add a deterministic seed flag (e.g., "--seed", "42") to
the run_simulate argument list used in this test to match the empirical band
derived with SmallRng::seed_from_u64(42); update the run_simulate invocation in
test_em_seq_partial_conversion_rate to include the seed so the RNG output is
stable across runs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5ad8e9d6-ca2a-493b-b546-0b4d5af557ad

📥 Commits

Reviewing files that changed from the base of the PR and between 4576552 and 37592f0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • Cargo.toml
  • README.md
  • src/commands/simulate.rs
  • src/fragment.rs
  • src/haplotype.rs
  • src/lib.rs
  • src/meth.rs
  • src/output/cpg_truth.rs
  • src/output/golden_bam.rs
  • src/output/mod.rs
  • src/read.rs
  • tests/helpers/mod.rs
  • tests/test_simulate_meth.rs
✅ Files skipped from review due to trivial changes (4)
  • src/output/mod.rs
  • src/lib.rs
  • README.md
  • Cargo.toml
🚧 Files skipped from review as they are similar to previous changes (5)
  • tests/helpers/mod.rs
  • src/fragment.rs
  • src/read.rs
  • src/meth.rs
  • src/output/golden_bam.rs

@nh13
nh13 force-pushed the nh/bisulfite-conversion branch from 37592f0 to 71de073 Compare May 5, 2026 05:29
@nh13

nh13 commented May 9, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13
nh13 force-pushed the nh/bisulfite-conversion branch from 05af86a to d240816 Compare May 9, 2026 17:57
@nh13 nh13 changed the title feat(simulate): add methylation chemistry simulation (em-seq and TAPS) feat(simulate): methylation chemistry with Bismark-compatible golden BAM May 9, 2026
@nh13

nh13 commented May 9, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 115-117: Update the README option descriptions for
--methylation-mode, --methylation-rate and --methylation-conversion-rate to
state that methylation is sampled per haplotype × strand (not once per reference
position), and that this enables allele-specific methylation and
variant-created/destroyed CpGs to be modeled; keep the existing default values
and chemistry names (em-seq, bisulfite, taps) but change the --methylation-rate
phrasing from "drawn once per genomic position" to "per-CpG methylation
probability sampled independently for each haplotype and strand" and add a brief
note that this allows allele-specific methylation patterns and variant-induced
CpG changes to be captured.

In `@src/commands/simulate.rs`:
- Around line 242-244: The code only discovers a missing parent directory for
--cpg-truth-bedgraph at write_bedgraph() after the expensive simulation; add an
early check right after option parsing (in the simulate command setup in
src/commands/simulate.rs) that, when self.cpg_truth_bedgraph.is_some(), verifies
the file's parent directory exists and is writable (e.g., obtain
Path::new(&self.cpg_truth_bedgraph.unwrap()).parent(), return an error via bail!
if parent is None or !parent.exists() or !is_writable); do the same guard where
similar outputs are validated (the other blocks referenced around lines ~264-270
and ~409-411) so we fail fast instead of discovering the problem in
write_bedgraph().
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2e6c4956-50d3-436d-918b-001bf54d3e36

📥 Commits

Reviewing files that changed from the base of the PR and between 4576552 and 05af86a.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (13)
  • Cargo.toml
  • README.md
  • src/commands/simulate.rs
  • src/fragment.rs
  • src/haplotype.rs
  • src/lib.rs
  • src/meth.rs
  • src/output/cpg_truth.rs
  • src/output/golden_bam.rs
  • src/output/mod.rs
  • src/read.rs
  • tests/helpers/mod.rs
  • tests/test_simulate_meth.rs
✅ Files skipped from review due to trivial changes (2)
  • Cargo.toml
  • src/output/mod.rs
🚧 Files skipped from review as they are similar to previous changes (9)
  • tests/helpers/mod.rs
  • src/lib.rs
  • src/output/cpg_truth.rs
  • src/fragment.rs
  • src/output/golden_bam.rs
  • src/read.rs
  • src/meth.rs
  • src/haplotype.rs
  • tests/test_simulate_meth.rs

Comment thread src/commands/simulate.rs
@nh13

nh13 commented May 9, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
tests/test_simulate_meth.rs (1)

161-187: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Seed this empirical-band test explicitly.

The comment says the 0.20..0.30 band was derived from seed 42, but the invocation never passes --seed. That makes the assertion depend on whatever auto-seeding path the CLI currently uses.

Suggested fix
         "--methylation-conversion-rate",
         "0.5",
+        "--seed",
+        "42",
         "--threads",
         "1",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_simulate_meth.rs` around lines 161 - 187, The test invocation in
tests/test_simulate_meth.rs calls run_simulate(...) without a fixed seed, but
the assertion relies on an empirical methylation band derived from seed 42; add
a deterministic seed flag to the CLI args (e.g. include "--seed" and "42" in the
argument list passed to run_simulate) so the empirical-band assertion remains
stable; update the run_simulate call where the arguments array is constructed to
include the seed parameter.
README.md (1)

115-117: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Document methylation sampling as per haplotype × strand, not per reference position.

The README still says the draw happens once per genomic position / “on the reference”, but the implementation samples each haplotype and strand independently. As written, this hides allele-specific methylation and variant-created/destroyed CpGs.

Suggested wording
-| `--methylation-rate` | 1.0 | Per-CpG methylation probability (drawn once per genomic position) |
+| `--methylation-rate` | 1.0 | Per-CpG methylation probability sampled independently for each haplotype and strand |
-CpG dinucleotides on the reference are independently methylated on each strand (top and bottom) with probability `--methylation-rate` (default `1.0`); the draw happens once per genomic position at simulation start and is deterministic from `--seed`.
+CpG dinucleotides on each haplotype are independently methylated on each strand (top and bottom) with probability `--methylation-rate` (default `1.0`); the draw happens once per haplotype × strand CpG site at simulation start and is deterministic from `--seed`. This allows allele-specific methylation and variant-created/destroyed CpGs to be modeled correctly.

Also applies to: 151-151

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 115 - 117, Update the README description for
methylation sampling to state that methylation is sampled independently per
haplotype and strand (not once per reference position); specifically revise the
lines describing `--methylation-rate` and `--methylation-conversion-rate` to
indicate "Per-CpG methylation probability drawn independently for each haplotype
× strand" and mention that this allows allele-specific methylation and
variant-created/destroyed CpGs; apply the same wording change to the other
occurrence noted around line 151.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@README.md`:
- Around line 185-187: The third invariant is incorrect: samtools calmd
recomputes standard SAM tags and does not preserve Bismark-style suppression of
allowed C>T / G>A events, so the README should be updated to avoid recommending
calmd as a validator; replace or reword the `samtools calmd` bullet (the text
mentioning `samtools calmd`, `MD:Z`, and `NM:i`) to state that calmd will
recompute standard NM/MD and can yield false failures on bisulfite-suppressed
BAMs and instead recommend using a bisulfite-aware comparator or a custom
validation script that treats C>T/G>A at CpGs as allowed (or simply remove the
calmd check).

In `@src/methylation_tags.rs`:
- Around line 379-382: The branch in populate_pair_call_tags that only sets
annotation.r2_call_tags when (read2, r2_truth, r2_cigar) are all Some(...)
leaves previous r2_call_tags intact when any of those are None; update the logic
so annotation.r2_call_tags is explicitly cleared (set to None) whenever the
tuple pattern does not match, i.e. when any of read2, r2_truth, or r2_cigar is
None, instead of leaving stale data—keep the compute_one_mate(...) assignment
for the Some case and add an explicit annotation.r2_call_tags = None in the
else/non-matching path to ensure stored tags are always overwritten.

---

Duplicate comments:
In `@README.md`:
- Around line 115-117: Update the README description for methylation sampling to
state that methylation is sampled independently per haplotype and strand (not
once per reference position); specifically revise the lines describing
`--methylation-rate` and `--methylation-conversion-rate` to indicate "Per-CpG
methylation probability drawn independently for each haplotype × strand" and
mention that this allows allele-specific methylation and
variant-created/destroyed CpGs; apply the same wording change to the other
occurrence noted around line 151.

In `@tests/test_simulate_meth.rs`:
- Around line 161-187: The test invocation in tests/test_simulate_meth.rs calls
run_simulate(...) without a fixed seed, but the assertion relies on an empirical
methylation band derived from seed 42; add a deterministic seed flag to the CLI
args (e.g. include "--seed" and "42" in the argument list passed to
run_simulate) so the empirical-band assertion remains stable; update the
run_simulate call where the arguments array is constructed to include the seed
parameter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c4715dcd-6fd9-45cf-951b-313167a47369

📥 Commits

Reviewing files that changed from the base of the PR and between 05af86a and d240816.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • Cargo.toml
  • README.md
  • src/commands/simulate.rs
  • src/fragment.rs
  • src/haplotype.rs
  • src/lib.rs
  • src/meth.rs
  • src/methylation_tags.rs
  • src/output/cpg_truth.rs
  • src/output/golden_bam.rs
  • src/output/mod.rs
  • src/read.rs
  • tests/helpers/mod.rs
  • tests/test_simulate_meth.rs
✅ Files skipped from review due to trivial changes (3)
  • src/lib.rs
  • Cargo.toml
  • src/output/mod.rs
🚧 Files skipped from review as they are similar to previous changes (7)
  • src/fragment.rs
  • tests/helpers/mod.rs
  • src/output/cpg_truth.rs
  • src/haplotype.rs
  • src/meth.rs
  • src/commands/simulate.rs
  • src/read.rs

Comment thread README.md Outdated
Comment thread src/methylation_tags.rs Outdated
@nh13
nh13 force-pushed the nh/bisulfite-conversion branch from d240816 to eba73ea Compare May 10, 2026 14:09
@nh13

nh13 commented May 10, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai resume

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
✅ Actions performed

Reviews resumed.

@nh13

nh13 commented May 10, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13

nh13 commented May 10, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13

nh13 commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13

nh13 commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13

nh13 commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13

nh13 commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13

nh13 commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13

nh13 commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@nh13

nh13 commented May 29, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Real bisulfite/EM-seq conversion is bimodal at the molecule level: most
molecules convert near-completely while a small fraction (fragments that
fail to denature or re-anneal too fast) escape conversion as a coherent
unit. The flat per-cytosine rate could not reproduce this, which is the
exact failure mode a conversion-failure detector must be benchmarked
against.

Draw a per-fragment camp once before the per-base loop: with probability
--methylation-failure-rate the molecule fails and converts its
should-convert cytosines at 1 - conversion_rate (near-zero), otherwise at
conversion_rate. The draw lives in apply_methylation_conversion, which runs
once per fragment, so both mates derive from the same converted buffer and
stay coherent. The golden BAM stamps every record with cf:i:{0|1} -- the
one piece of failure ground truth not recoverable from SEQ/YS alone.

Defaults now reflect realistic chemistry (the feature is unreleased, so no
byte-identical compatibility is owed): conversion-rate 1.0 -> 0.999,
failure-rate -> 0.01. Pass --methylation-failure-rate 0.0 with
--methylation-conversion-rate 1.0 for perfectly deterministic conversion.
@nh13

nh13 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/meth.rs (1)

92-109: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Return typed errors from these public methylation APIs instead of panicking.

MethylationTable::from_haplotype and apply_methylation_conversion are both pub, but invalid caller-supplied rates currently trip assert! and abort the process. That turns a recoverable library-input error into a crash path for external callers. Please move this validation onto a typed error surface (or an infallible validated config/newtype) and thread Result through the callers/tests instead. As per coding guidelines, "Use anyhow for application errors and thiserror for library error types".

Also applies to: 327-353

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meth.rs` around lines 92 - 109, Replace the panic-based validation in
MethylationTable::from_haplotype (and the same pattern in
apply_methylation_conversion) with a typed error return: remove the assert! and
validate methylation_rate, returning Result<Self, ErrorType> where ErrorType is
a library error defined with thiserror (e.g., MethylationError::InvalidRate {
value: f64 }), or alternatively accept a validated newtype; update the function
signatures to return Result and propagate that Result through callers and tests
using anyhow at application boundaries as needed, ensuring error messages
include the invalid value and use the existing function names
(MethylationTable::from_haplotype, apply_methylation_conversion) to locate
changes.

Source: Coding guidelines

src/commands/simulate.rs (1)

258-280: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Validate MT/MB coverage per simulated contig, not just once per VCF.

vcf_has_mt_mb_records(...) only proves that some annotated record exists somewhere in the file. run_simulation() still walks every reference contig, and the first contig without MT/MB falls into the "internal invariant" error path in simulate_contig instead of failing cleanly up front. That means a subset-contig methylated VCF can still waste a long run and leave partial outputs behind. Please preflight the parsed methylation records against the contigs you intend to simulate, or turn the per-contig None case into a normal user-facing validation error before opening writers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/simulate.rs` around lines 258 - 280, The current check using
crate::vcf::methylation::vcf_has_mt_mb_records only ensures some MT/MB records
exist anywhere in the VCF; update run_simulation() to preflight the parsed
methylation records against each reference contig you will simulate (use the
same contig list used by simulate_contig) and ensure every contig has MT/MB
coverage when self.methylation_mode.is_some(); alternatively, modify
simulate_contig to return a user-facing validation error (instead of an internal
panic) when a contig has no methylation records (i.e., the per-contig None
case), and surface that error before opening writers so the run fails fast with
a clear message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/commands/simulate.rs`:
- Around line 258-280: The current check using
crate::vcf::methylation::vcf_has_mt_mb_records only ensures some MT/MB records
exist anywhere in the VCF; update run_simulation() to preflight the parsed
methylation records against each reference contig you will simulate (use the
same contig list used by simulate_contig) and ensure every contig has MT/MB
coverage when self.methylation_mode.is_some(); alternatively, modify
simulate_contig to return a user-facing validation error (instead of an internal
panic) when a contig has no methylation records (i.e., the per-contig None
case), and surface that error before opening writers so the run fails fast with
a clear message.

In `@src/meth.rs`:
- Around line 92-109: Replace the panic-based validation in
MethylationTable::from_haplotype (and the same pattern in
apply_methylation_conversion) with a typed error return: remove the assert! and
validate methylation_rate, returning Result<Self, ErrorType> where ErrorType is
a library error defined with thiserror (e.g., MethylationError::InvalidRate {
value: f64 }), or alternatively accept a validated newtype; update the function
signatures to return Result and propagate that Result through callers and tests
using anyhow at application boundaries as needed, ensuring error messages
include the invalid value and use the existing function names
(MethylationTable::from_haplotype, apply_methylation_conversion) to locate
changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 72444353-4ae4-46fd-bacb-bf44b84d0ecf

📥 Commits

Reviewing files that changed from the base of the PR and between 80d4f9f and 0a49edd.

📒 Files selected for processing (8)
  • README.md
  • src/commands/simulate.rs
  • src/meth.rs
  • src/methylation_tags.rs
  • src/output/golden_bam.rs
  • src/read.rs
  • tests/test_methylate.rs
  • tests/test_simulate_meth.rs
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/output/golden_bam.rs
  • src/methylation_tags.rs
  • src/read.rs
  • tests/test_methylate.rs

Cleanup pass from a detailed branch review (opus/sonnet/haiku +
docs/test agents). No behavior change to the simulator outputs beyond
the bedGraph rate format fix.

- bedGraph: round the population-fraction rate to an integer percentage
  so methylate --bedgraph and simulate --cpg-truth-bedgraph share the
  MethylDackel `extract` format (a 1/3 site emitted 33.333... before).
- Dedupe the two byte-identical reference-CpG scanners into a single
  pub(crate) meth::find_reference_cpgs.
- Hoist the methylation default rates (0.999 / 0.01) to named constants
  so the seed string can't drift from the effective config defaults.
- Document the bitvec dependency rationale (8x memory at chromosome
  scale) at MethylationTable and in Cargo.toml.

Docs:
- CLAUDE.md: add the methylate subcommand and the five new methylation
  modules to the architecture table.
- README: document --min-fragment-length, --adapter-r1/r2, --golden-vcf
  (not yet implemented), and methylate's default METHYLATE sample name.

Tests (+6):
- conversion-rate out-of-range and NaN rejection
- bedGraph non-integer rate rounds to 33
- apply_methylation_conversion no-op at n_genomic == 0
- triploid MT/MB round-trip (ploidy > 2 per-haplotype path)
- TAPS+VCF integration: methylated SNP-created CpG converts to T
@nh13

nh13 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

nh13 and others added 2 commits June 13, 2026 11:54
* docs(simulate): document golden BAM header and per-record tags
* docs(simulate): document cf:i tag in golden BAM tag count
---------

Co-authored-by: Tim Fennell <tfenne@tfenne.com>
* feat(methylate): context-aware Markov methylation model

Replace the independent per-CpG Bernoulli draw with a biologically
realistic generator. This is a methylate-only change: the binary
per-haplotype MT/MB representation is unchanged, so simulate, the golden
BAM, chemistry, and the bedGraph writers are untouched.

Model:
- Each CpG is classified de novo (Gardiner-Garden criteria) into
  CpG-island interior / shore / open-sea. Islands are hypomethylated,
  open-sea hypermethylated, shores intermediate.
- A two-state (methylated/unmethylated) Markov chain walks each
  haplotype's CpG list with a keep-or-redraw transition: stationary mean
  equals the context's target rate, and autocorrelation decays with
  genomic distance per a per-context correlation length. This yields
  realistic spatial structure (runs, island/shore/sea level differences)
  instead of salt-and-pepper noise.
- Methylation is symmetric across strands by default; a low sporadic
  hemimethylation rate drops one strand per CpG. (The old model's
  independent strand draws overstated hemimethylation at 2p(1-p).)
- Per-haplotype independent chains preserve variant-aware CpG handling
  and give allele-specific methylation.

CLI: --methylation-rate is replaced by per-context rate flags
(--methylation-rate-island/-shore/-open-sea), per-context correlation
lengths, and --hemimethylation-rate. The no-flags default is now
biologically realistic rather than fully methylated; set the three rate
flags equal for uniform methylation.

Adds detector + walk unit tests (stationary mean, autocorrelation,
hemi rate, island hypomethylation, determinism) and methylate
integration tests; migrates existing call sites and the test helper.

* test(methylate): close coverage gaps from adversarial review

Multi-agent verification of the context-aware model found no correctness
or math defects; these address the Minor coverage/robustness findings:

- Add a shore-rate test: island + shore + open-sea with distinct rates,
  asserting the shore mean falls between island and open-sea. Guards the
  Shore branch of params_for, which every other test excluded.
- Add an allele-specific-methylation test: two haplotypes with identical
  CpG content must diverge, proving the per-haplotype chains are
  independent (rate-1.0/0.0 tests can't show this).
- Strengthen methylate_default_is_no_longer_fully_methylated: assert the
  mean per-CpG rate sits near the open-sea target (structural) instead of
  "at least one CpG < 100" (probabilistic); note it is seed-pinned.
- Doc precision: note the inclusive shore boundary; note the realized
  per-strand fraction m*(1-hemi_rate/2) when hemi_rate > 0. Fix two stale
  test comments referencing the removed --methylation-rate flag.

* refactor(meth): demote new methylation-model surface to pub(crate)

holodeck_lib is not consumed outside the crate, so the new model types
need no public API surface. Demote MethylationModel, ContextParams,
their fields, the DEFAULT_* constants, validate(), and the from_haplotype/
from_haplotypes constructors to pub(crate), matching CpgContext. No
behavior change.

* docs(meth): sync module overview with the Markov methylation model

The top-of-file //! overview still described the old independent
per-strand Bernoulli draws (and hemimethylation falling out of them),
which contradicted the updated item-level docs. Rewrite it to describe
the context-aware symmetric Markov model: CpgContext classification,
the two-state chain over ContextParams, symmetric methylation with
sporadic hemi_rate, and per-haplotype independence. (CodeRabbit, PR #14.)
@nh13

nh13 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/commands/methylate.rs (1)

162-189: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject output paths that alias the inputs or each other.

File::create(&self.output) and the optional bedGraph File::create(...) run before the later VCF parse and before contig loading finishes. If either output path points at --vcf or --reference, this command can clobber its own input; if --output == --bedgraph, both writers target the same file and corrupt each other. Fail fast on any path collision before opening either writer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/methylate.rs` around lines 162 - 189, Detect and reject any
conflicting paths before opening writers: check that self.output does not equal
the VCF input path (self.vcf.vcf) or the reference path (reference field on the
command struct), and if self.bedgraph.is_some() also ensure
self.bedgraph.as_ref() != Some(&self.output) and != self.vcf.vcf and !=
reference; if any equality is found return an Err with a clear message instead
of calling File::create. Make this check immediately before the current
File::create(&self.output) and bedGraph creation block (the code using
File::create(&self.output), the optional self.bedgraph creation and before
calling crate::vcf::parse_variants_by_contig/write_bedgraph_header) so you fail
fast on aliased input/output paths.
🧹 Nitpick comments (1)
src/commands/methylate.rs (1)

240-247: ⚡ Quick win

Use the no-variant bedGraph fast path here.

build_haplotypes(..., sample_ploidy, ...) makes haplotypes non-empty even on variant-free contigs, so this call never reaches write_bedgraph_records's haplotypes.is_empty() branch and rematerializes every reference-identical haplotype again. Passing an empty slice when variants.is_empty() preserves the output but avoids the extra per-haplotype extraction work on the common no-variant path.

♻️ Proposed fix
             if let Some(bg) = bedgraph_writer.as_mut() {
+                let bedgraph_haplotypes = if variants.is_empty() {
+                    &[][..]
+                } else {
+                    haplotypes.as_slice()
+                };
                 crate::output::methylation_bedgraph::write_bedgraph_records(
                     bg,
                     contig_name,
                     &reference,
-                    &haplotypes,
+                    bedgraph_haplotypes,
                     &methylation,
                 )?;
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/methylate.rs` around lines 240 - 247, The call into
write_bedgraph_records should use the no-variant fast path: when the local
`variants.is_empty()` is true, pass an empty haplotype slice instead of
`&haplotypes` to avoid rematerializing reference-identical haplotypes; change
the argument to something like `if variants.is_empty() { &[] } else {
&haplotypes }` in the block where you call
crate::output::methylation_bedgraph::write_bedgraph_records (the surrounding
code that previously used `build_haplotypes(..., sample_ploidy, ...)` can remain
unchanged).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_methylate.rs`:
- Around line 224-234: The helper parse_bedgraph currently swallows malformed
rows by using filter_map; change it to fail tests on bad input by replacing the
filter_map with a mapping that validates shape and parse success (e.g., split
the line into cols, assert the expected column count and use expect/unwrap with
a clear message when parsing cols[1] or cols[3] fails) so parse_bedgraph
explicitly panics on malformed bedGraph lines instead of skipping them; update
the function named parse_bedgraph to validate cols.len() (and/or check indices
exist) and to unwrap/expect parse results with informative error text.

---

Outside diff comments:
In `@src/commands/methylate.rs`:
- Around line 162-189: Detect and reject any conflicting paths before opening
writers: check that self.output does not equal the VCF input path (self.vcf.vcf)
or the reference path (reference field on the command struct), and if
self.bedgraph.is_some() also ensure self.bedgraph.as_ref() != Some(&self.output)
and != self.vcf.vcf and != reference; if any equality is found return an Err
with a clear message instead of calling File::create. Make this check
immediately before the current File::create(&self.output) and bedGraph creation
block (the code using File::create(&self.output), the optional self.bedgraph
creation and before calling
crate::vcf::parse_variants_by_contig/write_bedgraph_header) so you fail fast on
aliased input/output paths.

---

Nitpick comments:
In `@src/commands/methylate.rs`:
- Around line 240-247: The call into write_bedgraph_records should use the
no-variant fast path: when the local `variants.is_empty()` is true, pass an
empty haplotype slice instead of `&haplotypes` to avoid rematerializing
reference-identical haplotypes; change the argument to something like `if
variants.is_empty() { &[] } else { &haplotypes }` in the block where you call
crate::output::methylation_bedgraph::write_bedgraph_records (the surrounding
code that previously used `build_haplotypes(..., sample_ploidy, ...)` can remain
unchanged).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b98e37d2-ddd9-4823-ab8c-f80f28cdff3f

📥 Commits

Reviewing files that changed from the base of the PR and between 1fa4bb4 and f8ac01e.

📒 Files selected for processing (5)
  • README.md
  • src/commands/methylate.rs
  • src/meth.rs
  • tests/helpers/mod.rs
  • tests/test_methylate.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/helpers/mod.rs
  • README.md

Comment thread tests/test_methylate.rs
Comment on lines +224 to +234
/// Parse a population-fraction bedGraph into `(start, rate)` pairs, skipping
/// the track header line.
fn parse_bedgraph(contents: &str) -> Vec<(usize, u32)> {
contents
.lines()
.filter(|l| !l.starts_with("track"))
.filter_map(|l| {
let cols: Vec<&str> = l.split('\t').collect();
Some((cols.get(1)?.parse().ok()?, cols.get(3)?.parse().ok()?))
})
.collect()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make malformed bedGraph rows fail the test instead of disappearing.

filter_map turns parse/shape errors into skipped lines, so a broken writer can still satisfy the downstream mean/range assertions as long as some rows remain parseable. This helper should assert the expected column count and parse success so format regressions fail loudly.

💚 Proposed fix
 fn parse_bedgraph(contents: &str) -> Vec<(usize, u32)> {
     contents
         .lines()
         .filter(|l| !l.starts_with("track"))
-        .filter_map(|l| {
-            let cols: Vec<&str> = l.split('\t').collect();
-            Some((cols.get(1)?.parse().ok()?, cols.get(3)?.parse().ok()?))
+        .map(|l| {
+            let cols: Vec<&str> = l.split('\t').collect();
+            assert_eq!(cols.len(), 6, "unexpected bedGraph row shape: {l}");
+            let start = cols[1].parse().expect("bedGraph start must be an integer");
+            let rate = cols[3].parse().expect("bedGraph rate must be an integer");
+            (start, rate)
         })
         .collect()
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Parse a population-fraction bedGraph into `(start, rate)` pairs, skipping
/// the track header line.
fn parse_bedgraph(contents: &str) -> Vec<(usize, u32)> {
contents
.lines()
.filter(|l| !l.starts_with("track"))
.filter_map(|l| {
let cols: Vec<&str> = l.split('\t').collect();
Some((cols.get(1)?.parse().ok()?, cols.get(3)?.parse().ok()?))
})
.collect()
/// Parse a population-fraction bedGraph into `(start, rate)` pairs, skipping
/// the track header line.
fn parse_bedgraph(contents: &str) -> Vec<(usize, u32)> {
contents
.lines()
.filter(|l| !l.starts_with("track"))
.map(|l| {
let cols: Vec<&str> = l.split('\t').collect();
assert_eq!(cols.len(), 6, "unexpected bedGraph row shape: {l}");
let start = cols[1].parse().expect("bedGraph start must be an integer");
let rate = cols[3].parse().expect("bedGraph rate must be an integer");
(start, rate)
})
.collect()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_methylate.rs` around lines 224 - 234, The helper parse_bedgraph
currently swallows malformed rows by using filter_map; change it to fail tests
on bad input by replacing the filter_map with a mapping that validates shape and
parse success (e.g., split the line into cols, assert the expected column count
and use expect/unwrap with a clear message when parsing cols[1] or cols[3]
fails) so parse_bedgraph explicitly panics on malformed bedGraph lines instead
of skipping them; update the function named parse_bedgraph to validate
cols.len() (and/or check indices exist) and to unwrap/expect parse results with
informative error text.

@tfenne
tfenne merged commit ff816f3 into main Jun 13, 2026
5 checks passed
@tfenne
tfenne deleted the nh/bisulfite-conversion branch June 13, 2026 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants