Friday, July 10, 2026

Pratyāhāra and Dhāraṇā: The Bridge Between the Noisy World and Meditation

After yama, niyama, āsana, and prāṇāyāma, yoga reaches an interesting turning point. Until now, practice has been visible: ethics, discipline, posture, breath. But then yoga asks the practitioner to enter subtler territory.

This is where pratyāhāra and dhāraṇā appear.

They are often translated too quickly as “withdrawal of senses” and “concentration,” but that makes them sound dry, almost like exam instructions. In reality, they are among the most psychologically sophisticated parts of yoga.

They answer two essential questions:

Pratyāhāra: Can I stop being dragged around by every stimulus?
Dhāraṇā: Can I place my attention somewhere and keep it there?

Together, they form the bridge between ordinary awareness and meditation.


Where they sit in the eight limbs of yoga

LimbSanskritMeaning
1YamaEthical restraints
2NiyamaPersonal observances
3ĀsanaPosture
4PrāṇāyāmaBreath regulation
5PratyāhāraWithdrawal or mastery of the senses
6DhāraṇāConcentration
7DhyānaMeditation
8SamādhiAbsorption

Pratyāhāra and dhāraṇā are transitional limbs. They are not yet meditation, but without them meditation remains mostly a nice idea wearing loose cotton.


What is pratyāhāra?

Pratyāhāra comes from two parts: prati, meaning “against” or “back,” and āhāra, meaning “intake” or “food.” So pratyāhāra can be understood as withdrawing from what the senses feed upon.

It does not mean destroying the senses. It does not mean becoming deaf, blind, or indifferent. It means the senses stop ruling the mind.

Normally, our attention is pulled outward:

  • a phone notification,
  • someone’s tone of voice,
  • a smell from the kitchen,
  • a passing memory,
  • a sudden itch,
  • a social media urge,
  • the heroic gravitational field of snacks.

The senses keep throwing hooks into the mind. Pratyāhāra is the art of gently unhooking.

It is not sensory hatred. It is sensory freedom.


Anecdote 1: The student and the notification demon 📱

Imagine a student sitting to study. The book is open. The pen is ready. The mind has made a treaty with productivity.

Then the phone glows.

Nothing dramatic happens. No thunder. No earthquake. Just a tiny rectangle of light. But attention collapses. The hand moves before thought appears. One message becomes one app, one app becomes one video, one video becomes half an hour of digital wandering.

This is the opposite of pratyāhāra. The senses saw a stimulus, and the mind followed like a puppy chasing a scooter.

Now imagine the same student notices the phone glow but does not pick it up. The eyes register the light. The brain knows it happened. But attention does not obey. The student returns to the page.

That is pratyāhāra in modern form.

Not blindness.
Not suppression.
Freedom from being kidnapped.


Pratyāhāra is not escape

Many people misunderstand pratyāhāra as withdrawal from the world. But yoga is not asking us to become furniture. Pratyāhāra does not mean refusing life. It means refusing compulsion.

A person practicing pratyāhāra can enjoy music, food, conversation, beauty, fragrance, and touch. The difference is that enjoyment does not become slavery.

You can taste the sweet without needing the entire box.
You can hear criticism without becoming it.
You can see praise without getting drunk on it.
You can notice discomfort without immediately running away.

Pratyāhāra creates a gap between stimulus and reaction. In that gap, yoga begins sharpening its quiet knife.


How pratyāhāra feels in practice

In practice, pratyāhāra may begin very simply.

You sit. You close your eyes. Sounds continue. Someone coughs. A bike passes. A dog declares its political opinions outside. The body itches. The mind says, “This is not ideal. I require Himalayan silence and maybe better cushions.”

But gradually, you stop chasing every sound. The sound exists, but it does not become your center. The body exists, but every sensation does not demand a committee meeting. The senses keep reporting, but they are no longer commanders.

The world does not disappear. Its authority weakens.

That is pratyāhāra.


What is dhāraṇā?

After pratyāhāra comes dhāraṇā, usually translated as concentration.

The word comes from the root dhṛ, meaning “to hold.” Dhāraṇā is the act of holding attention on one chosen point.

That point may be:

  • the breath,
  • a mantra,
  • a candle flame,
  • a chakra,
  • a mental image,
  • a philosophical idea,
  • a sensation,
  • the space between the eyebrows,
  • the movement of inhalation and exhalation.

If pratyāhāra is closing the doors to unnecessary visitors, dhāraṇā is inviting one guest to sit at the center of the room.


Anecdote 2: The archer and the bird’s eye 🏹

A classic Indian teaching story describes young archers being tested by their teacher. A wooden bird is placed on a tree. The students are asked what they see.

One says, “I see the tree, branches, leaves, sky, bird.”

Another says, “I see the bird.”

Arjuna says, “I see only the eye of the bird.”

That is dhāraṇā.

Not because the tree vanished. Not because Arjuna’s eyes became magical. But because attention became selective, steady, and obedient.

Dhāraṇā is the mind learning to stop leaking.


Dhāraṇā is effortful concentration

This is the crucial distinction: dhāraṇā still requires effort.

You place the mind on the breath. It wanders. You bring it back. It wanders again. You bring it back again. This repeated returning is dhāraṇā.

It is not failure. It is the practice.

Many people sit for two minutes, discover that the mind wanders, and conclude, “I cannot meditate.” But that discovery is not a defeat. It is the first honest census of the mind’s monkey population.

In dhāraṇā, every return is a repetition. Like lifting a weight, but the muscle is attention.


How pratyāhāra and dhāraṇā differ

They are closely related, but not the same.

AspectPratyāhāraDhāraṇā
Main actionWithdrawing from sensory pullHolding attention on one point
DirectionAway from distractionToward chosen focus
Problem addressedThe senses drag the mind outwardThe mind cannot stay steady
ExampleHearing a sound but not following itKeeping attention on the breath
FeelingDetachment, inward turningEffortful focus
Place in yogaFifth limbSixth limb

A simple metaphor:

Pratyāhāra is turning down the noise.
Dhāraṇā is tuning into one note.


How are they different from meditation?

Meditation, or dhyāna, is the seventh limb. It comes after dhāraṇā.

The difference is subtle but beautiful.

In dhāraṇā, attention is repeatedly placed on the object. There is effort. The mind wanders, and you bring it back.

In dhyāna, attention becomes continuous. The effort becomes smoother. The interruptions reduce. Awareness flows toward the object like oil poured from one vessel into another.

StageWhat happens
PratyāhāraThe senses stop dragging attention outward
DhāraṇāAttention is held on one object with effort
DhyānaAttention flows continuously toward the object
SamādhiThe distinction between observer, observing, and object becomes very thin

Here is the simplest example.

You sit and focus on the breath.

At first, sounds and sensations keep pulling you away. Practicing not chasing them is pratyāhāra.

Then you repeatedly bring attention back to the breath. That is dhāraṇā.

After some time, attention stays with the breath naturally, without constant pulling and correcting. That is dhyāna.

Eventually, there may be only breath-awareness, without the usual chatter of “I am doing this well” or “How long has it been?” That approaches samādhi.


Anecdote 3: The musician and the vanishing room 🎻

A violinist begins practice in a noisy house. At first, every sound distracts her: footsteps, utensils, traffic, conversation.

She learns not to chase them. That is pratyāhāra.

Then she fixes attention on one phrase of music. Her fingers repeat it. Her ear listens carefully. When the mind drifts, she returns to the phrase. That is dhāraṇā.

After a while, something changes. She is no longer “trying to concentrate.” The music carries her. The room fades. The phrase becomes a river. That is closer to dhyāna.

And in rare moments, even the sense of “I am playing” dissolves into only music. That gives a small taste of samādhi.

The same sequence happens in yoga, art, science, writing, sport, prayer, and deep craft.


Why pratyāhāra comes before dhāraṇā

Trying to practice dhāraṇā without pratyāhāra is like trying to write a poem in a room where five televisions are shouting, two cats are negotiating territory, and your phone believes it is the emperor.

The mind cannot hold one object if it is still enslaved by every incoming stimulus.

Pratyāhāra prepares the field. It reduces sensory tyranny. Then dhāraṇā becomes possible.

This is why breath practice often helps. Prāṇāyāma calms the nervous system. A calmer nervous system makes pratyāhāra easier. Pratyāhāra makes dhāraṇā possible. Dhāraṇā matures into dhyāna.

The limbs are not random beads. They are a sequence.


Practical examples of pratyāhāra

1. Eating without being dragged by taste

You eat slowly. You taste fully. But you stop when the body has had enough.

That is pratyāhāra. The tongue reports pleasure, but the mind does not become a servant of the tongue.

2. Hearing criticism without collapsing

Someone criticizes your work. The ears hear the words. The ego flinches. But instead of instantly defending, attacking, or despairing, you pause.

You ask: Is there truth here? What can be used? What can be ignored?

That is pratyāhāra applied to emotional sound.

3. Seeing luxury without craving it

You see someone’s expensive car, house, clothes, phone, or academic success. The eyes register it. But the mind does not instantly produce a tragedy titled “My Life Is Insufficient.”

That is pratyāhāra.

4. Sitting with discomfort

During practice, a small itch appears. You notice it but do not immediately scratch. You observe how sensation rises, changes, and fades.

Not scratching every itch is a tiny monastery.


Practical examples of dhāraṇā

1. Breath counting

Count each exhalation from one to ten. If the mind wanders, return to one. Not angrily. Not dramatically. Just return.

That return is dhāraṇā doing push-ups.

2. Candle flame concentration

Look gently at a candle flame. Keep attention on its shape, movement, brightness, and still center. When thoughts arise, return to the flame.

The flame is not magic. It is a training post for attention.

3. Mantra repetition

Repeat a mantra mentally. Let the mind rest on its sound and rhythm. When other thoughts enter, return to the mantra.

Dhāraṇā here is not about belief alone. It is about continuity.

4. Scientific reading

Read one difficult paragraph without checking the phone, jumping tabs, or mentally rehearsing lunch. Hold attention on the argument.

This too is dhāraṇā. Academic concentration is a yogic event when performed with steadiness.


The difference between concentration and meditation in daily life

Let us use writing as an example.

You sit to write a blog post.

First, you close unnecessary tabs, silence the phone, and stop checking every sound. That is pratyāhāra.

Then you hold your mind on the central question: “What am I trying to say?” Each time your mind runs away, you bring it back. That is dhāraṇā.

After some time, the writing begins to flow. Sentences come. You are no longer forcing attention. You are inside the work. That is dhyāna-like.

If the self-conscious writer disappears and only the writing remains, you have touched something samādhi-like, at least in a small creative sense.

This is why meditation is not merely “sitting quietly.” It is the maturation of attention.


Common misunderstandings

Misunderstanding 1: Pratyāhāra means suppressing the senses

No. Suppression creates tension. Pratyāhāra creates freedom.

The sound may be present. The smell may be present. The sensation may be present. You simply stop being dragged.

Misunderstanding 2: Dhāraṇā means never getting distracted

No. Dhāraṇā includes distraction and return. The return is the practice.

A beginner who returns 100 times is not failing 100 times. They are training 100 times.

Misunderstanding 3: Meditation begins the moment you close your eyes

Not necessarily. Closing the eyes may simply reveal how loud the mind is.

Meditation begins when attention becomes continuous. Before that, we are usually practicing pratyāhāra and dhāraṇā.

Misunderstanding 4: These are only spiritual practices

They are also cognitive practices. Every serious artist, athlete, scholar, surgeon, musician, coder, and craftsperson knows these states in some form.

Pratyāhāra: stop being pulled away.
Dhāraṇā: stay with the work.
Dhyāna: enter the flow.


A small practice sequence

Here is a simple 10-minute practice to experience the difference.

Minute 1 to 2: Settle

Sit comfortably. Let the body become steady. Notice contact with the floor or chair.

Minute 2 to 4: Pratyāhāra

Notice sounds, sensations, smells, and thoughts. Do not fight them. Do not follow them. Let them remain at the edges.

The instruction is: “Known, but not followed.”

Minute 4 to 8: Dhāraṇā

Choose the breath at the nostrils. Hold attention there. When the mind wanders, return.

The instruction is: “This breath. Again.”

Minute 8 to 10: Taste of dhyāna

Stop counting returns. Let attention rest more softly. If the breath becomes continuous in awareness, stay with it.

The instruction is: “Let attention flow.”

You may or may not experience meditation. That is fine. The point is to learn the terrain.


Why this matters today

Modern life is almost perfectly designed to destroy pratyāhāra and dhāraṇā.

Notifications attack pratyāhāra.
Infinite scroll attacks dhāraṇā.
Outrage attacks ahimsa.
Performance attacks satya.
Comparison attacks aparigraha.

So these ancient limbs are not museum pieces. They are survival tools for the attention economy.

Pratyāhāra teaches you not to be eaten by stimuli.
Dhāraṇā teaches you to hold the lamp steady.
Dhyāna teaches you what happens when the lamp stops flickering.


Final reflection: before meditation, attention must come home

Pratyāhāra and dhāraṇā are often skipped in popular yoga discussions because they are less photogenic than postures and less mystical than meditation. But they are the hinge of the whole system.

Without pratyāhāra, the senses keep dragging the mind outward.
Without dhāraṇā, attention cannot stay anywhere long enough to deepen.
Without dhyāna, concentration remains effortful and fragmented.

They are three stages of the same homecoming.

First, you stop running after every sound.
Then, you choose one point and stay.
Then, staying becomes flowing.

That is the journey from pratyāhāra to dhāraṇā to meditation.

The world may still ring, glow, itch, tempt, and roar. But slowly, the practitioner learns a new skill:

to hear without chasing,
to see without grasping,
to focus without force,
and finally, to rest without scattering.

That is not escape from life.

That is attention returning to its throne. 🕯️

Thursday, July 9, 2026

The Word That Was Two Ideas in a Geological Overcoat

Stephen Jay Gould’s “Is Uniformitarianism Necessary?” is only six pages long, but it behaves like a small philosophical charge placed under one of geology’s most familiar words. The explosion is not loud. It is tidy, almost surgical. Gould does not begin by rejecting uniformitarianism wholesale, nor by defending it in the grand old style. He begins with a distinction: “Uniformitarianism is a dual concept.” That is the sentence that quietly splits the stone.

The article’s central claim is that geologists had been using one word for two different things. The first, which Gould calls “substantive uniformitarianism,” is a theory about Earth history. It claims, in broad form, that geologic change has proceeded with a certain uniformity of rates or material conditions. The second, “methodological uniformitarianism,” is not a theory of Earth’s tempo. It is a rule of scientific procedure: natural laws do not change arbitrarily across space and time. The same lawful order that lets us study rivers, volcanoes, glaciers, and organisms today also lets us infer something about rivers, volcanoes, glaciers, and organisms in the past.

This distinction sounds modest at first, but it carries enormous consequences. Gould’s abstract gives the verdict with striking directness. Substantive uniformitarianism is “false” and “stifling” when it prevents scientists from imagining non-uniform rates, unusual conditions, and exceptional events. Methodological uniformitarianism, by contrast, “belongs to the definition of science.” It is not wrong. It is too general to be geology’s private doctrine. It is the common grammar of empirical inquiry.

The genius of the article lies in its refusal to let a noble scientific inheritance remain vague simply because it is noble. Uniformitarianism had once been a heroic word. It helped geology break away from explanations that relied on divine interruption, special creation, and biblical compression of Earth history. It encouraged geologists to search for natural causes, not miracles. But Gould asks whether a concept that once liberated geology might later imprison it. The question is not whether uniformitarianism was historically important. It plainly was. The question is whether the term still clarifies anything, or whether it now causes more fog than illumination.

Gould’s answer depends on separating the word’s two lives. In one life, uniformitarianism meant that geological processes have operated with roughly the same intensity through time. This is the Lyellian picture most readers inherit in school: slow processes, acting over immense durations, produce great results. Small rivers carve canyons. Sediments accumulate. Erosion lowers mountains. Time performs the heavy lifting. Gould does not deny that such processes matter. He denies that the Earth must always behave according to that preferred rhythm. The fossil record, tectonic history, extinction patterns, climate transitions, and other evidence make strict uniformity of rate impossible to maintain.

In its second life, uniformitarianism meant something more fundamental: the past is scientifically intelligible because it was governed by natural law. This is methodological uniformitarianism. Gould takes it seriously, but he also deflates its special status. If geology is a science, then it already assumes lawful continuity. Physics assumes it. Biology assumes it. Chemistry assumes it. Astronomy assumes it. Why should geology need a special word for the shared assumption that makes empirical reasoning possible?

This is where the article becomes more than an argument about geology. It becomes a lesson in how scientific language ages. Some words begin as weapons. They are sharpened for battle. They organize communities. They defeat rivals. Later, after the battle is over, the same word may persist as a ceremonial object, polished and repeated, even as its practical use declines. Gould’s closing image captures this perfectly. The term, he says, risks becoming like “the crossbow in a nuclear age.” That is not contempt. It is historical perspective. The crossbow mattered. It deserves a museum label, not a launch code.

The first post in this series should therefore invite readers into Gould’s larger intellectual project: the cleaning of a concept. This is not mere pedantry. Words direct attention. If we confuse uniformity of laws with uniformity of rates, we may reject valid hypotheses because they look too catastrophic, too rapid, or too unfamiliar. Conversely, if we reject uniformitarianism entirely, we may appear to reject the lawful basis of science itself. One word can create a false choice between dogmatic gradualism and irrational chaos. Gould shows that we need neither.

The implications ripple outward. Public discussions of science are full of overloaded terms. “Theory” can mean a hunch or a well-supported explanatory framework. “Natural” can mean non-artificial, morally desirable, chemically unprocessed, or lawful. “Cause” can mean immediate trigger, background condition, statistical influence, or deep mechanism. Gould’s article is a reminder that many arguments survive because words are carrying more cargo than anyone has declared.

The article’s meaning, then, is partly philosophical and partly ethical. Gould models an intellectual honesty that refuses inherited confusion. He does not erase Lyell’s accomplishment. He does not mock the founders of geology. He does not turn historical usefulness into present necessity. He asks a mature question: what exactly should we keep?

In the end, “Is Uniformitarianism Necessary?” asks readers to distinguish gratitude from obedience. We can honor the past without letting it govern every new hypothesis. We can keep methodological naturalism without pretending Earth history unfolded at a constant pace. We can let “substantive uniformitarianism” retire as a failed general theory, while recognizing that “methodological uniformitarianism” is simply part of science itself.

The word was two words wearing one coat. Gould’s gift is to take the coat off, hang it in the historical wardrobe, and show us the two figures underneath: one a testable theory that failed in strict form, the other a general principle so basic that geology need not claim it as its own private talisman. That is why this little article still glints. It teaches us that conceptual clarity is not an accessory to science. It is one of science’s working tools, a hammer with a philosopher’s handle. 🪨

Filtering RNA Editing Candidates Without Losing the Biology

RNA editing discovery is a tiny-signal, huge-noise problem. A mismatch between RNA and the reference genome can be an ADAR edit, an APOBEC-like edit, a genomic SNP, a mapping artifact, a splice-junction alignment problem, a repeat-induced mirage, or a low-quality sequencing burp wearing a lab coat. The best filtering strategy is not just a sieve. It is a chain of cross-examinations.

This post explains the motivation behind each filter in a REDItools-style RNA editing workflow, shows how to implement the filters, summarizes one example run, and ends with a fully embedded, well-commented Python implementation. The implementation is included directly in the post so readers can inspect the logic without hunting for a separate script.

Example run at a glance

The sample summary used here contained 95,009,169 candidate rows. After filtering, 897 sites passed all filters, a pass rate of 0.000944%. The pipeline filtered 95,008,272 rows, or 99.999056% of the input.

The two largest attrition points were not glamorous but they were decisive:

66,320,554 rows had no observed non-reference RNA base after parsing.

28,170,388 rows had a mismatch between the REDItools reference base and the indexed FASTA reference.

Together, those two categories explain almost the entire candidate collapse. That is not necessarily bad. It means the pipeline is catching broad table-level or reference-level issues before expensive BAM pileups begin.

Filter-by-filter motivation and implementation

1. Parse defensively

REDItools tables can be tab-delimited, copied into whitespace-rendered formats, gzipped, or missing some genomic DNA fields. A fragile parser can create false edits before biology even enters the room. The implementation below validates numeric fields, parses `[A,C,G,T]` base-count vectors, records missing DNA evidence, and emits parse errors as explicit failure reasons.

Implementation principle: never silently coerce malformed rows into plausible-looking sites.

2. Normalize contig names

Reference FASTA files, BAMs, GTFs, and repeat annotations often disagree about names such as `chr1` versus `1`. The script builds contig alias maps so sites do not fail simply because one file speaks UCSC and another speaks Ensembl.

Implementation principle: resolve aliases at every interface, then report truly unresolved contigs.

3. Validate the reference base

For each candidate, the script fetches the FASTA base at the candidate coordinate and compares it with the REDItools reference base. If they differ, the candidate is rejected unless the user deliberately allows reference mismatches.

Why it matters: if the reference base is wrong, every substitution label downstream can be wrong. In the sample run, the complement-like mismatch classes A/T, T/A, G/C, and C/G were abundant, which is a useful warning flag for reference-build or strand-orientation auditing.

4. Require a real RNA alternate allele

The script chooses the most abundant non-reference RNA base as the candidate alternate allele. Editing level is calculated as:

```text

RNA editing level = RNA alternate-base count / total RNA A+C+G+T count

```

Rows with no non-reference RNA base are filtered as `no_rna_substitution`.

Implementation principle: use base counts, not only preformatted frequency strings. Keep secondary alternate alleles for QC, but classify the candidate using the major non-reference allele.

5. Enforce minimum RNA evidence

The example run required at least 10 RNA reads, at least 3 RNA alternate reads, at least 1% editing frequency, and RNA mean base quality of at least 25.

Why it matters: low-depth and low-quality sites are where sequencing errors put on tiny fake mustaches. Use both alternate-read count and editing fraction; either alone is incomplete.

6. Use matched DNA to reject genomic variants

If the same alternate allele appears strongly in DNA, the site is more likely to be a SNP or genomic variant than RNA editing. The example run rejected candidates with DNA alternate frequency above 5% or DNA alternate count above 3.

Implementation principle: matched DNA is the strongest ordinary shield against calling genomic variation as RNA editing. If REDItools DNA fields are absent, count DNA bases directly from the DNA BAM with quality thresholds.

7. Filter repeat-overlapping sites carefully

Repeats create alignment ambiguity. Short reads from similar repeat copies can pile up at the wrong locus and create false mismatches. However, repeats are also biologically important, especially for ADAR editing in primate Alu elements.

Implementation principle: for conservative discovery, filter repeats. For ADAR biology, stratify repeats instead of discarding them wholesale: unique-region sites, repeat-associated known sites, and repeat-associated novel candidates.

8. Filter homopolymers

Homopolymer runs are error-prone sequence contexts. The implementation measures the same-base run around the candidate directly from the reference FASTA.

Implementation principle: keep the threshold configurable and platform-aware. A long-read nanopore workflow and a short-read Illumina workflow do not have identical context-error profiles.

9. Remove splice-site-proximal candidates

Splice junctions are alignment origami. Split reads, clipped bases, and imperfect junction annotation can produce local mismatches. The implementation builds splice-site windows from transcript exon boundaries.

Implementation principle: choose the flank based on aligner quality and annotation confidence. The example used a 2 bp flank.

10. Require gene-model overlap

The implementation filters sites that do not overlap gene models. This keeps the final set focused on transcribed regions.

Implementation principle: make the feature choice explicit. Gene spans, exons, transcripts, UTRs, CDS, and introns answer different biological questions.

11. Check mapping quality in RNA and DNA BAMs

The script computes pileup depth, mean MAPQ, and the fraction of reads meeting the MAPQ threshold. Reads that are unmapped, secondary, supplementary, duplicate, or QC-fail are excluded by default.

Implementation principle: calculate mapping quality at the candidate position, not only from global alignment summaries.

12. Check nearby indels

Nearby insertions or deletions can shift alignments and produce false mismatches. The implementation scans a window around the candidate in RNA and DNA BAMs and records the worst indel burden observed.

Implementation principle: filter sites with too many indel-supporting reads or excessive indel fraction in the local window.

13. Measure local mismatch density

A candidate surrounded by many unrelated mismatches is suspicious. The script scans flanking bases, excludes the candidate itself, and calculates local mismatch density. It can ignore neighboring mismatches that match the same transcript-oriented edit type, which avoids punishing genuine editing clusters.

Implementation principle: do not treat all clusters as artifacts. ADAR editing often clusters, so same-event neighbors deserve different treatment from random mismatch confetti.

14. Classify in transcript orientation

A genomic `T>C` on a minus-strand gene corresponds to transcript `A>G`. The implementation uses overlapping gene strand to report transcript-oriented substitutions and assigns canonical `A>G` to ADAR and canonical `C>T` to APOBEC. Ambiguous strand cases are preserved rather than forced.

Implementation principle: always report both genomic and transcript-oriented substitutions.

What the result figures show

The pipeline generates two main figures.

The event-class count figure shows the number of retained sites by transcript-oriented substitution type. It answers: what survived the filter gauntlet? In this sample, the retained table contained the following event classes:

Event type Retained sites

`T>C` 436

`G>A` 88

`A>G` 83

`C>T` 53

`T>A` 34

`T>G` 30

`A>T` 29

`C>G` 28

`C>A` 25

`G>C` 24

`G>T` 23

`A>C` 22

`ambiguous_T>C` 9

`ambiguous_A>G` 6

`ambiguous_G>T` 3

`ambiguous_A>T` 1

`ambiguous_C>G` 1

`ambiguous_G>A` 1

`ambiguous_G>C` 1


The editing-level histogram figure shows the distribution of RNA alternate fractions for each retained event type. It answers: how strong is the RNA evidence among survivors? A suspicious event class with unusual editing-level shape is a prompt to inspect mapping context, DNA evidence, strand assignment, and repeat overlap.

Detailed filter summary from the sample run

Filter reason Count % of input

`no_rna_substitution` 66,320,554 69.804372%

`reference_mismatch_fasta:A_reditools:T` 7,726,490 8.132362%

`reference_mismatch_fasta:T_reditools:A` 7,722,575 8.128242%

`reference_mismatch_fasta:G_reditools:C` 6,406,203 6.742721%

`reference_mismatch_fasta:C_reditools:G` 6,315,120 6.646853%

`low_rna_alt_count` 451,033 0.474726%

`low_editing_frequency` 170,622 0.179585%

`low_rna_coverage` 130,207 0.137047%

`high_dna_alt_count_possible_snv` 87,270 0.091854%

`high_dna_alt_frequency_possible_snv` 79,901 0.084098%

`low_rna_mean_base_quality` 57,599 0.060625%

`no_gene_model_overlap` 42,551 0.044786%

`repeat_overlap` 27,414 0.028854%

`splice_site_proximity` 11,929 0.012556%

`low_dna_coverage` 2,172 0.002286%

`rna_too_many_indel_reads` 1,566 0.001648%

`rna_high_column_mismatch_fraction` 545 0.000574%

`rna_high_indel_fraction` 464 0.000488%

`dna_too_many_indel_reads` 366 0.000385%

`rna_no_usable_bam_reads` 334 0.000352%

`rna_high_window_mismatch_fraction` 240 0.000253%

`dna_low_good_mapq_fraction` 75 0.000079%

`dna_low_mean_mapq` 67 0.000071%

`low_dna_mean_base_quality` 7 0.000007%

`dna_high_indel_fraction` 5 0.000005%


These counts are not a perfectly exclusive waterfall. A site can receive multiple filter reasons within a filtering phase, so the filter-reason total can exceed the number of failed rows. That is useful: it tells us which kinds of badness co-occur.

Future improvements and recent developments

Move from hard thresholds to calibrated scores

Hard filters are transparent, but they create cliffs. A candidate with DNA alternate frequency 0.049 passes while 0.051 fails. A future version should keep the interpretable filters but also emit a calibrated probability or confidence tier based on RNA support, DNA evidence, mapping quality, local sequence context, repeat annotation, splice distance, and replicate concordance.

Add known-editing annotations

REDIportal V3.0 reports 15,680,833 sites from GTEx RNA-seq data and TCGA RNA-seq data, plus mouse nascent RNA-seq sites, and includes gene-view and dsRNA modules. Known-site annotation should not rescue a poor-quality site automatically, but it can help prioritize candidates for validation.

Use ensemble and replicate-aware callers

REDItools2/HPC-REDItools improves large-scale RNA editing detection through parallelization and faster interval processing. JACUSA2 can process BAM data from platforms including Illumina and ONT Nanopore. A strong future workflow could run multiple callers, then label sites as consensus, caller-specific, or discordant.

Support direct RNA sequencing and modification-aware basecalling

Nanopore direct RNA sequencing is becoming more relevant for RNA modification analysis because it observes native RNA molecules without cDNA conversion. Recent reports note that modification-aware basecallers now include inosine among supported modifications, but independent validation and compatibility with existing tools remain important. Synthetic benchmarking datasets for RNA modified bases, including inosine, are also becoming available.

Treat repeats as biology, not just noise

A mature pipeline should stratify repeat contexts. For example: unique-region sites, known repeat-associated edits, novel repeat-associated candidates, and low-mappability repeat artifacts. This avoids throwing away real ADAR biology with the artifact bathwater.

Add validation-first outputs

The output should include a validation priority score: recoding potential, known-site status, conservation, disease relevance, differential editing, replicate support, and enzyme-signature compatibility. The best pipeline does not merely say “passed.” It says “this one is worth chasing.”

Sources for the new-developments section

REDIportal V3.0 homepage and Nucleic Acids Research update.

REDItools2 GitHub documentation and HPC-REDItools publication.

JACUSA2 Genome Biology publication.

2026 review/assessment of nanopore direct RNA sequencing modification detection.

Oxford Nanopore/EPI2ME RNA modified-base benchmarking dataset.

---

Embedded, well-commented Python implementation

The script below is embedded so the blog post is self-contained. Comments have been added around the major design decisions, filter stages, and output logic.

<details>

<summary>Click to expand the annotated RNA editing filtering script</summary>

```python

#!/usr/bin/env python3

"""

Filter and classify candidate RNA editing sites from REDItools output.


Inputs:

  * REDItools table

  * RepeatMasker output, BED, or GFF-like repeat intervals

  * Genome FASTA, indexed with samtools faidx

  * RNA BAM, indexed

  * DNA BAM, indexed

  * GTF annotation


Main filters:

  * Repeat overlap

  * Homopolymer runs in the reference FASTA

  * Low RNA or DNA coverage and base quality

  * Possible genomic variants seen in the DNA counts

  * Poor mapping quality in RNA or DNA BAM pileups

  * Splice-site proximity from GTF exons

  * Indels in flanking regions from RNA or DNA BAM pileups

  * Absence from annotated gene models


Classification:

  * Uses the strand of overlapping gene models.

  * Reports the exact transcript-oriented substitution type, for example A>G, C>T, G>A.

  * Colors all possible ADAR-compatible changes (A>G / T>C) in blue,

    all APOBEC-compatible changes (C>T / G>A) in orange,

    and all remaining change types in grey.


Example:

  python filter_classify_rna_edits.py \

    --reditools sample.redi.tsv \

    --repeatmasker genome.fa.out \

    --genome genome.fa \

    --rna-bam rna.sorted.bam \

    --dna-bam dna.sorted.bam \

    --gtf annotation.gtf \

    --out-prefix results/sample


Dependencies:

  Python 3.6+

  pip install pysam matplotlib

"""


# Compatibility: tested to parse with Python 3.6 grammar; no future annotations import.

import argparse

import csv

import gzip

import logging

import math

import os

import re

import sys

from collections import Counter, defaultdict

from pathlib import Path

from typing import Dict, Generator, Iterable, List, Optional, Sequence, Tuple


try:

    import pysam

except ImportError as exc:  # pragma: no cover

    raise SystemExit(

        "Missing dependency: pysam. Install it with: pip install pysam"

    ) from exc



# -----------------------------------------------------------------------------

# Core nucleotide conventions

# -----------------------------------------------------------------------------

# Everything downstream assumes a fixed A/C/G/T order. This makes REDItools

# base-count vectors deterministic and prevents accidental allele swaps.

BASES = ("A", "C", "G", "T")

BASE_TO_I = {b: i for i, b in enumerate(BASES)}

COMPLEMENT = str.maketrans("ACGTNacgtn", "TGCANtgcan")


TRANSCRIPT_SUBSTITUTION_ORDER = [

    "A>G",  # canonical ADAR signal

    "C>T",  # canonical APOBEC signal

    "A>C",

    "A>T",

    "C>A",

    "C>G",

    "G>A",

    "G>C",

    "G>T",

    "T>A",

    "T>C",

    "T>G",

]



REDITOOLS_COLUMNS = [

    "Region",

    "Position",

    "Reference",

    "Strand",

    "Coverage-q30",

    "MeanQ",

    "BaseCount[A,C,G,T]",

    "AllSubs",

    "Frequency",

    "gCoverage-q30",

    "gMeanQ",

    "gBaseCount[A,C,G,T]",

    "gAllSubs",

    "gFrequency",

]


NUMBER_TOKEN = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+|nan|NaN|NA|-|\.)"

FREQ_TOKEN = r"(?:-|\.|[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[,;][+-]?(?:\d+(?:\.\d*)?|\.\d+))*)"

COUNT_TOKEN = r"(?:-|\[[^\]]+\])"


REDITOOLS_SPACE_RE = re.compile(

    r"^(\S+)\s+"                  # Region

    r"(\d+)\s+"                  # Position

    r"([ACGTNacgtn])\s+"         # Reference

    r"(\S+)\s+"                  # Strand

    r"(\d+|-)\s+"               # Coverage-q30

    r"(" + NUMBER_TOKEN + r")\s+"  # MeanQ

    r"(" + COUNT_TOKEN + r")\s+"   # BaseCount

    r"(.+?)\s+"                  # AllSubs

    r"(" + FREQ_TOKEN + r")\s+"    # Frequency

    r"(\d+|-)\s+"               # gCoverage-q30

    r"(" + NUMBER_TOKEN + r")\s+"  # gMeanQ

    r"(" + COUNT_TOKEN + r")\s+"   # gBaseCount

    r"(.+?)\s+"                  # gAllSubs

    r"(" + FREQ_TOKEN + r")\s*$"   # gFrequency

)



class EditPipelineError(RuntimeError):

    """Raised for user-fixable pipeline errors."""



# -----------------------------------------------------------------------------

# Lightweight interval indexing

# -----------------------------------------------------------------------------

# Repeat intervals, gene models, and splice windows are all represented as

# genomic intervals. The small binned index below supports fast point queries

# without requiring an external interval-tree dependency.

class Interval:

    def __init__(self, start, end, data):

        self.start = start

        self.end = end

        self.data = data



class BinnedIntervalIndex:

    """A small point-query interval index based on fixed-size genomic bins."""


    def __init__(self, bin_size: int = 10000) -> None:

        if bin_size <= 0:

            raise ValueError("bin_size must be positive")

        self.bin_size = bin_size

        self._bins: Dict[str, Dict[int, List[Interval]]] = defaultdict(lambda: defaultdict(list))

        self.interval_count = 0


    def add(self, chrom: str, start: int, end: int, data: Optional[dict] = None) -> None:

        if start < 0:

            start = 0

        if end <= start:

            return

        interval = Interval(start=start, end=end, data=data or {})

        first_bin = start // self.bin_size

        last_bin = (end - 1) // self.bin_size

        for bin_id in range(first_bin, last_bin + 1):

            self._bins[chrom][bin_id].append(interval)

        self.interval_count += 1


    def query_point(self, chrom: str, pos0: int) -> List[Interval]:

        if pos0 < 0:

            return []

        bin_id = pos0 // self.bin_size

        hits = []

        for iv in self._bins.get(chrom, {}).get(bin_id, []):

            if iv.start <= pos0 < iv.end:

                hits.append(iv)

        return hits


    def __len__(self) -> int:

        return self.interval_count



# -----------------------------------------------------------------------------

# Contig-name normalization

# -----------------------------------------------------------------------------

# Genomes, BAMs, GTFs, and repeat files often disagree about chromosome names

# such as 'chr1' versus '1'. The resolver maps common aliases so biologically

# identical contigs are not treated as missing.

class ContigResolver:

    """Resolve common contig naming differences such as chr1 versus 1."""


    def __init__(self, names: Sequence[str]) -> None:

        self.names = set(names)

        self.alias: Dict[str, str] = {}

        for name in names:

            self.alias[name] = name

            if name.startswith("chr"):

                self.alias.setdefault(name[3:], name)

            else:

                self.alias.setdefault("chr" + name, name)

        if "MT" in self.names:

            self.alias.setdefault("M", "MT")

            self.alias.setdefault("chrM", "MT")

        if "M" in self.names:

            self.alias.setdefault("MT", "M")

            self.alias.setdefault("chrM", "M")

        if "chrM" in self.names:

            self.alias.setdefault("M", "chrM")

            self.alias.setdefault("MT", "chrM")


    def resolve(self, chrom: str) -> Optional[str]:

        return self.alias.get(chrom)



class BamContext:

    def __init__(self, label, bam, resolver):

        self.label = label

        self.bam = bam

        self.resolver = resolver



class GtfData:

    def __init__(self, genes, splice_sites, loaded_gene_intervals, loaded_splice_intervals):

        self.genes = genes

        self.splice_sites = splice_sites

        self.loaded_gene_intervals = loaded_gene_intervals

        self.loaded_splice_intervals = loaded_splice_intervals



class Evaluation:

    def __init__(self, passed, reasons, record, event_class=None, editing_level=None, event_group=None):

        self.passed = passed

        self.reasons = reasons

        self.record = record

        self.event_class = event_class

        self.editing_level = editing_level

        self.event_group = event_group



# -----------------------------------------------------------------------------

# Robust input parsing helpers

# -----------------------------------------------------------------------------

# The pipeline accepts plain text, gzipped text, and stdin. REDItools files are

# sometimes exported or pasted in slightly different layouts, so parsing is

# deliberately defensive.

def open_text(path: str):

    """Open plain text, gzip text, or stdin."""

    if path == "-":

        return sys.stdin

    if str(path).endswith(".gz"):

        return gzip.open(path, "rt", encoding="utf-8", errors="replace")

    return open(path, "rt", encoding="utf-8", errors="replace")



def is_int_string(value: str) -> bool:

    try:

        int(value)

        return True

    except ValueError:

        return False



def is_number_string(value: str) -> bool:

    try:

        float(value)

        return True

    except ValueError:

        return False



MISSING_FIELD_VALUES = {"", "-", ".", "NA", "N/A", "nan", "NaN"}



def is_missing_field(value: str) -> bool:

    return value is None or str(value).strip() in MISSING_FIELD_VALUES



def parse_int(value: str, field: str, line_no: int, missing_as=None) -> int:

    if is_missing_field(value):

        if missing_as is not None:

            return missing_as

        raise ValueError(f"line {line_no}: missing integer field {field}={value!r}")

    try:

        return int(value)

    except (TypeError, ValueError) as exc:

        raise ValueError(f"line {line_no}: could not parse integer field {field}={value!r}") from exc



def parse_float(value: str, field: str, line_no: int) -> float:

    value = str(value).strip()

    if is_missing_field(value):

        return math.nan

    try:

        return float(value)

    except (TypeError, ValueError) as exc:

        raise ValueError(f"line {line_no}: could not parse float field {field}={value!r}") from exc



def parse_base_count(value: str, line_no: int, missing_as_zero: bool = False) -> List[int]:

    value = str(value).strip()

    if is_missing_field(value):

        if missing_as_zero:

            return [0, 0, 0, 0]

        raise ValueError(f"line {line_no}: missing base-count field {value!r}")

    if not (value.startswith("[") and value.endswith("]")):

        raise ValueError(f"line {line_no}: malformed base-count field {value!r}")

    parts = [x.strip() for x in value[1:-1].split(",")]

    if len(parts) != 4:

        raise ValueError(f"line {line_no}: expected 4 base counts but found {len(parts)}")

    try:

        counts = [int(x) for x in parts]

    except ValueError as exc:

        raise ValueError(f"line {line_no}: base counts must be integers: {value!r}") from exc

    if any(x < 0 for x in counts):

        raise ValueError(f"line {line_no}: base counts cannot be negative: {value!r}")

    return counts



def canonical_reditools_name(name):

    """Normalize REDItools column names while keeping the public output names stable."""

    key = str(name).strip().lstrip("#").strip().lower()

    key = re.sub(r"\s+", "", key)

    key = key.replace("_", "-")

    aliases = {

        "region": "Region",

        "chrom": "Region",

        "chromosome": "Region",

        "seqid": "Region",

        "position": "Position",

        "pos": "Position",

        "reference": "Reference",

        "ref": "Reference",

        "strand": "Strand",

        "coverage-q30": "Coverage-q30",

        "coverage": "Coverage-q30",

        "meanq": "MeanQ",

        "basecount[a,c,g,t]": "BaseCount[A,C,G,T]",

        "basecount": "BaseCount[A,C,G,T]",

        "allsubs": "AllSubs",

        "frequency": "Frequency",

        "gcov": "gCoverage-q30",

        "gcoverage": "gCoverage-q30",

        "gcoverage-q30": "gCoverage-q30",

        "gmeanq": "gMeanQ",

        "gbasecount[a,c,g,t]": "gBaseCount[A,C,G,T]",

        "gbasecount": "gBaseCount[A,C,G,T]",

        "gallsubs": "gAllSubs",

        "gfrequency": "gFrequency",

    }

    return aliases.get(key, str(name).strip())



def looks_like_reditools_header(line):

    low = line.lower()

    return ("region" in low and "position" in low and "reference" in low and

            ("basecount" in low or "base count" in low))



def split_delimited_reditools_line(line, delimiter):

    stripped = line.rstrip("\n\r")

    if delimiter == "\t":

        return [x.strip() for x in stripped.split("\t")]

    if delimiter == ",":

        reader = csv.reader([stripped])

        return [x.strip() for x in next(reader)]

    return None



def split_reditools_line(line, delimiter=None):

    """

    Split a REDItools row.


    REDItools tables are usually tab-delimited, but rendered examples are often

    pasted as whitespace-delimited text. BaseCount fields contain spaces inside

    brackets, so a naive line.split() corrupts the columns. This function first

    respects an explicit delimiter from the header, then falls back to a regex

    that treats bracketed base-count vectors as single fields.

    """

    stripped = line.rstrip("\n\r")

    if delimiter in ("\t", ","):

        parts = split_delimited_reditools_line(stripped, delimiter)

        if parts and len(parts) >= 5:

            return parts

    if "\t" in stripped:

        parts = [x.strip() for x in stripped.split("\t")]

        if len(parts) >= 5:

            return parts

    if looks_like_reditools_header(stripped):

        parts = re.split(r"\s+", stripped.strip())

        if len(parts) >= 5:

            return parts

    match = REDITOOLS_SPACE_RE.match(stripped.strip())

    if match:

        return list(match.groups())

    return None



def build_reditools_header_map(header_parts):

    """Return canonical column positions for a REDItools header line."""

    mapping = {}

    for i, name in enumerate(header_parts):

        canonical = canonical_reditools_name(name)

        if canonical in REDITOOLS_COLUMNS and canonical not in mapping:

            mapping[canonical] = i

    required = ["Region", "Position", "Reference", "Coverage-q30", "MeanQ", "BaseCount[A,C,G,T]"]

    missing = [c for c in required if c not in mapping]

    if missing:

        raise EditPipelineError(

            "REDItools header is missing required column(s): " + ", ".join(missing)

        )

    return mapping



def row_from_parts(parts, header_map=None):

    """Build a canonical REDItools row dict from split fields."""

    if header_map is not None:

        row = {}

        for col in REDITOOLS_COLUMNS:

            idx = header_map.get(col)

            row[col] = parts[idx].strip() if idx is not None and idx < len(parts) else "-"

        return row

    # No usable header was seen. Fall back to the classic 14-column REDItools order.

    if len(parts) < len(REDITOOLS_COLUMNS):

        return None

    return dict(zip(REDITOOLS_COLUMNS, [x.strip() for x in parts[:len(REDITOOLS_COLUMNS)]]))



def validate_reditools_row_fields(row, line_no):

    """Parse numeric/count fields in place and annotate missing DNA evidence."""

    row["Position"] = parse_int(row["Position"], "Position", line_no)

    row["Coverage-q30"] = parse_int(row["Coverage-q30"], "Coverage-q30", line_no, missing_as=0)

    row["MeanQ"] = parse_float(row["MeanQ"], "MeanQ", line_no)

    row["BaseCount[A,C,G,T]"] = parse_base_count(row["BaseCount[A,C,G,T]"], line_no)

    # REDItools sometimes writes '-' for genomic DNA fields. Treat those as missing

    # instead of malformed: coverage/counts become zero and quality becomes NA.

    row["_missing_gCoverage_q30"] = is_missing_field(row["gCoverage-q30"])

    row["_missing_gMeanQ"] = is_missing_field(row["gMeanQ"])

    row["_missing_gBaseCount"] = is_missing_field(row["gBaseCount[A,C,G,T]"])

    row["gCoverage-q30"] = parse_int(row["gCoverage-q30"], "gCoverage-q30", line_no, missing_as=0)

    row["gMeanQ"] = parse_float(row["gMeanQ"], "gMeanQ", line_no)

    row["gBaseCount[A,C,G,T]"] = parse_base_count(row["gBaseCount[A,C,G,T]"], line_no, missing_as_zero=True)

    row["Reference"] = str(row["Reference"]).strip().upper()

    row["Region"] = str(row["Region"]).strip()

    row["Strand"] = str(row.get("Strand", ".")).strip()

    row["AllSubs"] = str(row.get("AllSubs", "-")).strip()

    row["Frequency"] = str(row.get("Frequency", "-")).strip()

    row["gAllSubs"] = str(row.get("gAllSubs", "-")).strip()

    row["gFrequency"] = str(row.get("gFrequency", "-")).strip()

    return row



# -----------------------------------------------------------------------------

# REDItools parser

# -----------------------------------------------------------------------------

# This function yields one parsed row at a time. It validates numeric fields,

# converts base-count strings into integer vectors, and records missing DNA

# evidence explicitly instead of pretending it is high-quality zero evidence.

def parse_reditools(path):

    """

    Yield parsed REDItools rows as dictionaries.


    This parser is header-aware. The attached example uses the canonical order:

    Region, Position, Reference, Strand, Coverage-q30, MeanQ,

    BaseCount[A,C,G,T], AllSubs, Frequency, gCoverage-q30, gMeanQ,

    gBaseCount[A,C,G,T], gAllSubs, gFrequency.

    """

    header_map = None

    delimiter = None

    warned_no_header = False

    with open_text(path) as handle:

        for line_no, line in enumerate(handle, start=1):

            stripped = line.strip()

            if not stripped or stripped.startswith("#"):

                continue

            if looks_like_reditools_header(stripped):

                if "\t" in line:

                    delimiter = "\t"

                elif "," in line and "BaseCount[A,C,G,T]" not in line:

                    delimiter = ","

                else:

                    delimiter = None

                header_parts = split_reditools_line(line, delimiter=delimiter)

                if header_parts is None:

                    raise EditPipelineError("Could not parse REDItools header at line %s" % line_no)

                header_map = build_reditools_header_map(header_parts)

                logging.info(

                    "Detected REDItools columns from header at line %s: %s",

                    line_no,

                    ", ".join([c for c in REDITOOLS_COLUMNS if c in header_map])

                )

                continue

            parts = split_reditools_line(line, delimiter=delimiter)

            if parts is None:

                logging.warning("Skipping malformed REDItools line %s", line_no)

                yield line_no, {"_parse_error": "malformed_reditools_line", "_raw": stripped}

                continue

            if header_map is None and not warned_no_header:

                warned_no_header = True

                logging.warning(

                    "REDItools header was not detected before data line %s; assuming classic 14-column order.",

                    line_no

                )

            row = row_from_parts(parts, header_map=header_map)

            if row is None:

                logging.warning("Skipping malformed REDItools line %s: expected at least %s columns, found %s", line_no, len(REDITOOLS_COLUMNS), len(parts))

                yield line_no, {"_parse_error": "malformed_reditools_line", "_raw": stripped}

                continue

            try:

                validate_reditools_row_fields(row, line_no)

            except ValueError as exc:

                logging.warning("Skipping REDItools line %s: %s", line_no, exc)

                yield line_no, {"_parse_error": str(exc), "_raw": stripped}

                continue

            yield line_no, row



def parse_gtf_attributes(attr_text: str) -> dict:

    attrs = {}

    for field in attr_text.strip().rstrip(";").split(";"):

        field = field.strip()

        if not field:

            continue

        if "=" in field and " " not in field.split("=", 1)[0]:

            key, value = field.split("=", 1)

        else:

            parts = field.split(None, 1)

            if len(parts) == 1:

                key, value = parts[0], ""

            else:

                key, value = parts

        attrs[key.strip()] = value.strip().strip('"')

    return attrs



# -----------------------------------------------------------------------------

# Gene-model and splice-site annotation

# -----------------------------------------------------------------------------

# The GTF contributes two biological filters: gene overlap and splice-junction

# proximity. Gene overlap keeps candidates in transcribed models; splice windows

# remove sites most likely to be affected by split-read alignment uncertainty.

def load_gtf(path: str, resolver: ContigResolver, overlap_feature: str, splice_flank: int) -> GtfData:

    genes = BinnedIntervalIndex()

    splice_sites = BinnedIntervalIndex()

    exon_by_transcript: Dict[str, List[Tuple[str, int, int, str, str, str]]] = defaultdict(list)

    exon_by_gene: Dict[str, List[Tuple[str, int, int, str, str]]] = defaultdict(list)

    skipped_contigs = Counter()

    gene_interval_count = 0


    with open_text(path) as handle:

        for line_no, line in enumerate(handle, start=1):

            if not line.strip() or line.startswith("#"):

                continue

            fields = line.rstrip("\n").split("\t")

            if len(fields) != 9:

                logging.warning("Skipping malformed GTF line %s", line_no)

                continue

            seqid, source, feature, start_s, end_s, score, strand, frame, attrs_s = fields

            chrom = resolver.resolve(seqid)

            if chrom is None:

                skipped_contigs[seqid] += 1

                continue

            try:

                start0 = int(start_s) - 1

                end = int(end_s)

            except ValueError:

                logging.warning("Skipping GTF line %s with invalid coordinates", line_no)

                continue

            if start0 < 0 or end <= start0:

                logging.warning("Skipping GTF line %s with invalid interval", line_no)

                continue

            attrs = parse_gtf_attributes(attrs_s)

            gene_id = (

                attrs.get("gene_id")

                or attrs.get("gene")

                or attrs.get("ID")

                or attrs.get("Parent")

                or f"unknown_gene_line_{line_no}"

            )

            gene_name = attrs.get("gene_name") or attrs.get("Name") or gene_id

            transcript_id = attrs.get("transcript_id") or attrs.get("transcript") or attrs.get("Parent")


            if feature == overlap_feature:

                genes.add(

                    chrom,

                    start0,

                    end,

                    {

                        "gene_id": gene_id,

                        "gene_name": gene_name,

                        "strand": strand,

                        "feature": feature,

                    },

                )

                gene_interval_count += 1


            if feature == "exon":

                exon_by_gene[gene_id].append((chrom, start0, end, strand, gene_name))

                if transcript_id:

                    exon_by_transcript[transcript_id].append((chrom, start0, end, strand, gene_id, gene_name))


    if gene_interval_count == 0:

        logging.warning(

            "No GTF feature=%r intervals found. Falling back to gene spans built from exon records.",

            overlap_feature,

        )

        for gene_id, exons in exon_by_gene.items():

            by_chrom_strand: Dict[Tuple[str, str, str], List[Tuple[int, int]]] = defaultdict(list)

            for chrom, start0, end, strand, gene_name in exons:

                by_chrom_strand[(chrom, strand, gene_name)].append((start0, end))

            for (chrom, strand, gene_name), intervals in by_chrom_strand.items():

                start0 = min(x[0] for x in intervals)

                end = max(x[1] for x in intervals)

                genes.add(

                    chrom,

                    start0,

                    end,

                    {

                        "gene_id": gene_id,

                        "gene_name": gene_name,

                        "strand": strand,

                        "feature": "exon_span_fallback",

                    },

                )

                gene_interval_count += 1


    if splice_flank > 0:

        for transcript_id, exons in exon_by_transcript.items():

            if len(exons) < 2:

                continue

            exons_sorted = sorted(exons, key=lambda x: (x[0], x[1], x[2]))

            for left, right in zip(exons_sorted, exons_sorted[1:]):

                left_chrom, left_start, left_end, strand, gene_id, gene_name = left

                right_chrom, right_start, right_end, _, _, _ = right

                if left_chrom != right_chrom:

                    continue

                for boundary in (left_end, right_start):

                    splice_sites.add(

                        left_chrom,

                        max(0, boundary - splice_flank),

                        boundary + splice_flank,

                        {

                            "gene_id": gene_id,

                            "gene_name": gene_name,

                            "transcript_id": transcript_id,

                            "strand": strand,

                        },

                    )


    if skipped_contigs:

        top = ", ".join(f"{k}:{v}" for k, v in skipped_contigs.most_common(5))

        logging.warning("Skipped GTF records on contigs absent from FASTA: %s", top)


    if len(genes) == 0:

        raise EditPipelineError("No usable gene intervals were loaded from the GTF file.")


    return GtfData(

        genes=genes,

        splice_sites=splice_sites,

        loaded_gene_intervals=len(genes),

        loaded_splice_intervals=len(splice_sites),

    )



def parse_repeatmasker_interval(parts: List[str], fmt: str) -> Optional[Tuple[str, int, int, str]]:

    """Return chrom, start0, end, repeat_name or None.


    Supported repeat inputs:

      * BED: chrom start0 end [name ...]

      * GFF/GTF-like: chrom source feature start1 end1 ... attributes

      * RepeatMasker .out:

          SW perc perc perc query begin end (left) strand repeat class/family ... ID


    RepeatMasker coordinates are 1-based closed, so begin is converted to

    0-based half-open for internal overlap tests.

    """

    requested_rmout = fmt in {"auto", "rmout", "repeatmasker"}


    if fmt in {"auto", "bed"} and len(parts) >= 3 and is_int_string(parts[1]) and is_int_string(parts[2]):

        chrom = parts[0]

        start0 = int(parts[1])

        end = int(parts[2])

        name = parts[3] if len(parts) >= 4 else "repeat"

        return chrom, start0, end, name


    if fmt in {"auto", "gff"} and len(parts) >= 9 and is_int_string(parts[3]) and is_int_string(parts[4]):

        chrom = parts[0]

        start0 = int(parts[3]) - 1

        end = int(parts[4])

        attrs = parse_gtf_attributes(parts[8])

        name = attrs.get("Name") or attrs.get("Target") or parts[2]

        return chrom, start0, end, name


    # Standard RepeatMasker .out row, e.g.:

    # 1435 15.5 11.8 2.8 NC_046332.1 2 365 (117953686) C DR0009509 LTR/ERVL (535) 397 2 1

    #   18 14.7  0.0 0.0 NC_046332.1 2065 2094 (117951957) + (T)n Simple_repeat 1 30 (0) 2

    # A trailing asterisk can be present in some .out files, so only the

    # coordinate/name columns needed here are required.

    if requested_rmout and len(parts) >= 11 and is_number_string(parts[0]):

        chrom = parts[4]

        if not (is_int_string(parts[5]) and is_int_string(parts[6])):

            return None

        start1 = int(parts[5])

        end1 = int(parts[6])

        if start1 <= 0 or end1 <= 0:

            return None

        start0 = min(start1, end1) - 1

        end = max(start1, end1)

        repeat_name = parts[9]

        repeat_class = parts[10] if len(parts) > 10 else "repeat"

        name = f"{repeat_name}|{repeat_class}"

        return chrom, start0, end, name


    return None



# -----------------------------------------------------------------------------

# Repeat annotation loader

# -----------------------------------------------------------------------------

# RepeatMasker .out, BED, and GFF-like repeat files are normalized into the

# same 0-based half-open coordinate system used internally by Python/pysam.

def load_repeats(path: str, resolver: ContigResolver, fmt: str) -> BinnedIntervalIndex:

    repeats = BinnedIntervalIndex()

    skipped = Counter()

    malformed = 0

    with open_text(path) as handle:

        for line_no, line in enumerate(handle, start=1):

            stripped = line.strip()

            if not stripped or stripped.startswith("#"):

                continue

            if stripped.lower().startswith(("sw ", "score ", "there were", "repeatmasker")):

                continue

            parts = stripped.split()

            parsed = parse_repeatmasker_interval(parts, fmt)

            if parsed is None:

                malformed += 1

                if malformed <= 5:

                    logging.warning("Skipping unrecognized repeat line %s", line_no)

                continue

            chrom_raw, start0, end, name = parsed

            chrom = resolver.resolve(chrom_raw)

            if chrom is None:

                skipped[chrom_raw] += 1

                continue

            if end <= start0:

                malformed += 1

                continue

            repeats.add(chrom, start0, end, {"repeat_name": name})

    if skipped:

        top = ", ".join(f"{k}:{v}" for k, v in skipped.most_common(5))

        logging.warning("Skipped repeat records on contigs absent from FASTA: %s", top)

    logging.info("Loaded %s repeat intervals", len(repeats))

    return repeats



# -----------------------------------------------------------------------------

# File and indexed reference/BAM validation

# -----------------------------------------------------------------------------

# These checks fail early with user-fixable messages: missing FASTA index,

# missing BAM index, or absent input files should not become cryptic pileup errors.

def validate_file(path: str, label: str, allow_stdin: bool = False) -> None:

    if allow_stdin and path == "-":

        return

    if not path:

        raise EditPipelineError(f"Missing path for {label}.")

    if not os.path.exists(path):

        raise EditPipelineError(f"{label} does not exist: {path}")

    if not os.path.isfile(path):

        raise EditPipelineError(f"{label} is not a regular file: {path}")



def open_fasta(path: str) -> pysam.FastaFile:

    try:

        fasta = pysam.FastaFile(path)

    except Exception as exc:

        raise EditPipelineError(

            f"Could not open genome FASTA {path!r}. Make sure it is indexed with: samtools faidx {path}"

        ) from exc

    if not fasta.references:

        raise EditPipelineError("The genome FASTA has no references.")

    return fasta



def open_bam(path: str, label: str) -> pysam.AlignmentFile:

    try:

        bam = pysam.AlignmentFile(path, "rb")

    except Exception as exc:

        raise EditPipelineError(f"Could not open {label} BAM {path!r}.") from exc

    try:

        has_index = bam.has_index()

    except Exception:

        has_index = False

    if not has_index:

        raise EditPipelineError(

            f"{label} BAM is not indexed or the index is not readable: {path}. Run: samtools index {path}"

        )

    return bam



def fasta_base(fasta: pysam.FastaFile, chrom: str, pos0: int) -> Optional[str]:

    try:

        if pos0 < 0 or pos0 >= fasta.get_reference_length(chrom):

            return None

        seq = fasta.fetch(chrom, pos0, pos0 + 1).upper()

    except Exception:

        return None

    return seq if len(seq) == 1 else None



# -----------------------------------------------------------------------------

# Sequence-context filter: homopolymers

# -----------------------------------------------------------------------------

# Homopolymer runs are error-prone sequence contexts. The function measures the

# same-base run containing the candidate directly from the FASTA.

def homopolymer_run_length(

    fasta: pysam.FastaFile,

    chrom: str,

    pos0: int,

    threshold: int,

) -> int:

    if threshold <= 1:

        return 1

    contig_len = fasta.get_reference_length(chrom)

    start = max(0, pos0 - threshold + 1)

    end = min(contig_len, pos0 + threshold)

    seq = fasta.fetch(chrom, start, end).upper()

    idx = pos0 - start

    if idx < 0 or idx >= len(seq):

        return 0

    base = seq[idx]

    if base not in BASE_TO_I:

        return 0

    run = 1

    left = idx - 1

    while left >= 0 and seq[left] == base:

        run += 1

        left -= 1

    right = idx + 1

    while right < len(seq) and seq[right] == base:

        run += 1

        right += 1

    return run



def usable_alignment(aln, ignore_duplicates: bool, ignore_qcfail: bool) -> bool:

    if aln.is_unmapped or aln.is_secondary or aln.is_supplementary:

        return False

    if ignore_duplicates and aln.is_duplicate:

        return False

    if ignore_qcfail and aln.is_qcfail:

        return False

    return True



# -----------------------------------------------------------------------------

# BAM site-level mapping-quality filter

# -----------------------------------------------------------------------------

# A real edit should not be supported mainly by ambiguously mapped reads. This

# function computes mean MAPQ and the fraction of reads above the MAPQ threshold.

def site_mapq_stats(

    bam_ctx: BamContext,

    chrom: str,

    pos0: int,

    min_mapq: int,

    min_mean_mapq: float,

    min_good_frac: float,

    max_depth: int,

    ignore_duplicates: bool,

    ignore_qcfail: bool,

) -> Tuple[bool, dict, str]:

    bam_chrom = bam_ctx.resolver.resolve(chrom)

    if bam_chrom is None:

        return False, {}, "contig_not_found_in_bam"

    total = 0

    good = 0

    mapq_sum = 0

    try:

        pileup_iter = bam_ctx.bam.pileup(

            bam_chrom,

            pos0,

            pos0 + 1,

            truncate=True,

            stepper="all",

            max_depth=max_depth,

            min_base_quality=0,

        )

        for col in pileup_iter:

            if col.reference_pos != pos0:

                continue

            for pr in col.pileups:

                aln = pr.alignment

                if not usable_alignment(aln, ignore_duplicates, ignore_qcfail):

                    continue

                if pr.is_del or pr.is_refskip:

                    continue

                total += 1

                mq = int(aln.mapping_quality)

                mapq_sum += mq

                if mq >= min_mapq:

                    good += 1

    except ValueError as exc:

        return False, {}, f"bam_pileup_failed:{exc}"

    except Exception as exc:

        return False, {}, f"bam_pileup_failed:{type(exc).__name__}"


    if total == 0:

        return False, {"depth": 0, "mean_mapq": math.nan, "good_mapq_fraction": math.nan}, "no_usable_bam_reads"

    mean_mapq = mapq_sum / total

    good_frac = good / total

    stats = {"depth": total, "mean_mapq": mean_mapq, "good_mapq_fraction": good_frac}

    if mean_mapq < min_mean_mapq:

        return False, stats, "low_mean_mapq"

    if good_frac < min_good_frac:

        return False, stats, "low_good_mapq_fraction"

    return True, stats, "pass"



# -----------------------------------------------------------------------------

# DNA BAM fallback genotype counter

# -----------------------------------------------------------------------------

# If REDItools lacks DNA counts, the pipeline can genotype the same position

# from the DNA BAM using MAPQ and base-quality thresholds.

def bam_base_counts_at_site(

    bam_ctx: BamContext,

    chrom: str,

    pos0: int,

    min_mapq: int,

    min_baseq: int,

    max_depth: int,

    ignore_duplicates: bool,

    ignore_qcfail: bool,

) -> Tuple[bool, dict, str]:

    """Count A/C/G/T bases in a BAM at one site for DNA fallback genotyping."""

    bam_chrom = bam_ctx.resolver.resolve(chrom)

    if bam_chrom is None:

        return False, {}, "contig_not_found_in_bam"

    counts = [0, 0, 0, 0]

    qual_sum = 0

    try:

        pileup_iter = bam_ctx.bam.pileup(

            bam_chrom,

            pos0,

            pos0 + 1,

            truncate=True,

            stepper="all",

            max_depth=max_depth,

            min_base_quality=0,

        )

        for col in pileup_iter:

            if col.reference_pos != pos0:

                continue

            for pr in col.pileups:

                aln = pr.alignment

                if not usable_alignment(aln, ignore_duplicates, ignore_qcfail):

                    continue

                if int(aln.mapping_quality) < min_mapq:

                    continue

                if pr.is_del or pr.is_refskip or pr.query_position is None:

                    continue

                seq = aln.query_sequence

                if not seq or pr.query_position >= len(seq):

                    continue

                base = seq[pr.query_position].upper()

                if base not in BASE_TO_I:

                    continue

                q = 0

                if aln.query_qualities is not None and pr.query_position < len(aln.query_qualities):

                    q = int(aln.query_qualities[pr.query_position])

                if q < min_baseq:

                    continue

                counts[BASE_TO_I[base]] += 1

                qual_sum += q

    except ValueError as exc:

        return False, {}, f"bam_pileup_failed:{exc}"

    except Exception as exc:

        return False, {}, f"bam_pileup_failed:{type(exc).__name__}"


    depth = sum(counts)

    if depth == 0:

        return False, {"counts": counts, "coverage": 0, "mean_baseq": math.nan}, "no_usable_dna_bases"

    return True, {"counts": counts, "coverage": depth, "mean_baseq": qual_sum / depth}, "pass"



# -----------------------------------------------------------------------------

# Local alignment-instability filter: nearby indels

# -----------------------------------------------------------------------------

# Indels near a candidate can shift alignments and create false mismatches. The

# filter scans a small window and records the worst indel burden observed.

def indel_stats_in_window(

    bam_ctx: BamContext,

    chrom: str,

    pos0: int,

    flank: int,

    max_indel_frac: float,

    max_indel_reads: int,

    max_depth: int,

    ignore_duplicates: bool,

    ignore_qcfail: bool,

) -> Tuple[bool, dict, str]:

    if flank <= 0:

        return True, {"max_indel_fraction": 0.0, "max_indel_reads": 0}, "pass"

    bam_chrom = bam_ctx.resolver.resolve(chrom)

    if bam_chrom is None:

        return False, {}, "contig_not_found_in_bam"

    start = max(0, pos0 - flank)

    end = pos0 + flank + 1

    max_frac_seen = 0.0

    max_indel_seen = 0

    columns_seen = 0

    try:

        pileup_iter = bam_ctx.bam.pileup(

            bam_chrom,

            start,

            end,

            truncate=True,

            stepper="all",

            max_depth=max_depth,

            min_base_quality=0,

        )

        for col in pileup_iter:

            if not (start <= col.reference_pos < end):

                continue

            columns_seen += 1

            depth = 0

            indel_reads = 0

            for pr in col.pileups:

                aln = pr.alignment

                if not usable_alignment(aln, ignore_duplicates, ignore_qcfail):

                    continue

                depth += 1

                if pr.indel != 0 or pr.is_del or pr.is_refskip:

                    indel_reads += 1

            if depth > 0:

                frac = indel_reads / depth

                max_frac_seen = max(max_frac_seen, frac)

                max_indel_seen = max(max_indel_seen, indel_reads)

    except ValueError as exc:

        return False, {}, f"bam_pileup_failed:{exc}"

    except Exception as exc:

        return False, {}, f"bam_pileup_failed:{type(exc).__name__}"


    stats = {"max_indel_fraction": max_frac_seen, "max_indel_reads": max_indel_seen, "columns_seen": columns_seen}

    if max_indel_seen > max_indel_reads:

        return False, stats, "too_many_indel_reads"

    if max_frac_seen > max_indel_frac:

        return False, stats, "high_indel_fraction"

    return True, stats, "pass"



# -----------------------------------------------------------------------------

# Local mismatch-density filter

# -----------------------------------------------------------------------------

# A candidate surrounded by unrelated mismatches is suspicious. The candidate

# base itself is excluded, and same-event neighboring edits can be ignored so

# genuine editing clusters are not automatically punished.

def mismatch_stats_in_window(

    bam_ctx: BamContext,

    fasta: pysam.FastaFile,

    chrom: str,

    pos0: int,

    flank: int,

    min_mapq: int,

    min_baseq: int,

    max_window_mismatch_fraction: float,

    max_column_mismatch_fraction: float,

    max_high_mismatch_columns: int,

    max_depth: int,

    ignore_duplicates: bool,

    ignore_qcfail: bool,

    gene_strand: str,

    ignore_event_type: str,

    count_same_event_mismatches: bool,

) -> Tuple[bool, dict, str]:

    """Detect locally mismapped regions by measuring mismatch density around a site.


    The candidate position itself is excluded so a genuine edit does not count

    against the window. By default, adjacent mismatches matching the same

    transcript-oriented substitution are not counted, which keeps true local

    A>G or C>T clusters from looking like mapping artefacts. A site fails when

    adjacent columns show too many other bases that disagree with the reference FASTA.

    """

    if flank <= 0:

        return True, {

            "window_mismatch_fraction": 0.0,

            "max_mismatch_fraction": 0.0,

            "high_mismatch_columns": 0,

            "mismatch_columns_seen": 0,

            "mismatch_bases": 0,

            "window_bases": 0,

        }, "pass"

    bam_chrom = bam_ctx.resolver.resolve(chrom)

    if bam_chrom is None:

        return False, {}, "contig_not_found_in_bam"


    try:

        contig_len = fasta.get_reference_length(chrom)

    except Exception:

        return False, {}, "fasta_reference_length_failed"

    start = max(0, pos0 - flank)

    end = min(contig_len, pos0 + flank + 1)

    if end <= start:

        return False, {}, "empty_mismatch_window"

    try:

        ref_seq = fasta.fetch(chrom, start, end).upper()

    except Exception as exc:

        return False, {}, "fasta_fetch_failed:%s" % type(exc).__name__


    total_bases = 0

    mismatch_bases = 0

    max_frac_seen = 0.0

    high_columns = 0

    columns_seen = 0

    try:

        pileup_iter = bam_ctx.bam.pileup(

            bam_chrom,

            start,

            end,

            truncate=True,

            stepper="all",

            max_depth=max_depth,

            min_base_quality=0,

        )

        for col in pileup_iter:

            ref_pos = col.reference_pos

            if not (start <= ref_pos < end):

                continue

            if ref_pos == pos0:

                continue

            idx = ref_pos - start

            if idx < 0 or idx >= len(ref_seq):

                continue

            ref_base = ref_seq[idx]

            if ref_base not in BASE_TO_I:

                continue

            depth = 0

            mismatches = 0

            for pr in col.pileups:

                aln = pr.alignment

                if not usable_alignment(aln, ignore_duplicates, ignore_qcfail):

                    continue

                if int(aln.mapping_quality) < min_mapq:

                    continue

                if pr.is_del or pr.is_refskip or pr.query_position is None:

                    continue

                seq = aln.query_sequence

                if not seq or pr.query_position >= len(seq):

                    continue

                base = seq[pr.query_position].upper()

                if base not in BASE_TO_I:

                    continue

                q = 0

                if aln.query_qualities is not None and pr.query_position < len(aln.query_qualities):

                    q = int(aln.query_qualities[pr.query_position])

                if q < min_baseq:

                    continue

                depth += 1

                total_bases += 1

                if base != ref_base:

                    if (not count_same_event_mismatches) and gene_strand in {"+", "-"} and ignore_event_type:

                        try:

                            local_change = transcript_change(ref_base, base, gene_strand)

                        except Exception:

                            local_change = ""

                        if local_change == ignore_event_type:

                            continue

                    mismatches += 1

                    mismatch_bases += 1

            if depth > 0:

                columns_seen += 1

                frac = mismatches / depth

                max_frac_seen = max(max_frac_seen, frac)

                if frac > max_column_mismatch_fraction:

                    high_columns += 1

    except ValueError as exc:

        return False, {}, "bam_pileup_failed:%s" % exc

    except Exception as exc:

        return False, {}, "bam_pileup_failed:%s" % type(exc).__name__


    window_frac = (mismatch_bases / total_bases) if total_bases > 0 else 0.0

    stats = {

        "window_mismatch_fraction": window_frac,

        "max_mismatch_fraction": max_frac_seen,

        "high_mismatch_columns": high_columns,

        "mismatch_columns_seen": columns_seen,

        "mismatch_bases": mismatch_bases,

        "window_bases": total_bases,

    }

    if total_bases == 0:

        return True, stats, "pass"

    if window_frac > max_window_mismatch_fraction:

        return False, stats, "high_window_mismatch_fraction"

    if max_frac_seen > max_column_mismatch_fraction:

        return False, stats, "high_column_mismatch_fraction"

    if high_columns > max_high_mismatch_columns:

        return False, stats, "too_many_high_mismatch_columns"

    return True, stats, "pass"



# -----------------------------------------------------------------------------

# RNA alternate-allele selection and editing-level calculation

# -----------------------------------------------------------------------------

# The major non-reference RNA base becomes the candidate edit allele. Editing

# level is simply alternate RNA reads divided by total A/C/G/T RNA reads.

def major_rna_alt(ref: str, counts: List[int]) -> Tuple[Optional[str], int, float, List[str]]:

    if ref not in BASE_TO_I:

        return None, 0, 0.0, []

    total = sum(counts)

    if total <= 0:

        return None, 0, 0.0, []

    ref_i = BASE_TO_I[ref]

    alt_items = [(base, counts[i]) for i, base in enumerate(BASES) if i != ref_i and counts[i] > 0]

    if not alt_items:

        return None, 0, 0.0, []

    alt_items.sort(key=lambda x: (-x[1], x[0]))

    alt_base, alt_count = alt_items[0]

    secondary = [f"{base}:{count}" for base, count in alt_items[1:]]

    return alt_base, alt_count, alt_count / total, secondary



def transcript_change(ref: str, alt: str, strand: str) -> str:

    if strand == "+":

        return f"{ref}>{alt}"

    if strand == "-":

        tref = ref.translate(COMPLEMENT).upper()

        talt = alt.translate(COMPLEMENT).upper()

        return f"{tref}>{talt}"

    return "NA"



# -----------------------------------------------------------------------------

# Transcript-oriented event classification

# -----------------------------------------------------------------------------

# ADAR/APOBEC labels only make biological sense in transcript orientation. A

# genomic T>C on a minus-strand gene is transcript A>G, for example.

def classify_event(ref: str, alt: str, gene_hits: List[Interval]) -> Tuple[str, str, str, str, str, str]:

    """Classify by transcript-oriented substitution without collapsing Other types.


    Returns:

      event_group: ADAR, APOBEC, or Other

      event_type: transcript-oriented change such as A>G or C>T

      event_label: label used in plots. Usually same as event_type; ambiguous

                   strand cases are labelled ambiguous_REF>ALT.

      gene_ids, gene_names, gene_strand_used

    """

    gene_ids = sorted({iv.data.get("gene_id", "NA") for iv in gene_hits})

    gene_names = sorted({iv.data.get("gene_name", "NA") for iv in gene_hits})

    strands = sorted({iv.data.get("strand", ".") for iv in gene_hits if iv.data.get("strand") in {"+", "-"}})

    if len(strands) != 1:

        genomic_change = f"{ref}>{alt}"

        event_type = "ambiguous_" + genomic_change

        return "Other", event_type, event_type, ",".join(gene_ids), ",".join(gene_names), "ambiguous_or_missing_gene_strand"


    strand = strands[0]

    event_type = transcript_change(ref, alt, strand)

    if event_type == "A>G":

        event_group = "ADAR"

    elif event_type == "C>T":

        event_group = "APOBEC"

    else:

        event_group = "Other"

    return event_group, event_type, event_type, ",".join(gene_ids), ",".join(gene_names), strand


def fmt_float(value: float, digits: int = 6) -> str:

    if value is None or (isinstance(value, float) and math.isnan(value)):

        return "NA"

    return f"{value:.{digits}f}"



# -----------------------------------------------------------------------------

# The filtering heart of the pipeline

# -----------------------------------------------------------------------------

# evaluate_site() applies filters from cheap and deterministic checks first

# toward expensive BAM pileups later. This ordering keeps the run efficient and

# keeps failure reasons interpretable.

def evaluate_site(

    row: dict,

    line_no: int,

    fasta: pysam.FastaFile,

    fasta_resolver: ContigResolver,

    repeats: BinnedIntervalIndex,

    gtf_data: GtfData,

    bam_contexts: List[BamContext],

    args,

) -> Evaluation:

    # 1. Reject malformed rows before doing any biological interpretation.

    if "_parse_error" in row:

        return Evaluation(False, ["parse_error"], None)


    reasons: List[str] = []

    # 2. Harmonize chromosome names and convert REDItools' 1-based position

    #    into the 0-based coordinate system used by FASTA/BAM queries.

    chrom_raw = str(row["Region"])

    chrom = fasta_resolver.resolve(chrom_raw)

    if chrom is None:

        return Evaluation(False, ["contig_not_found_in_fasta"], None)


    pos1 = int(row["Position"])

    pos0 = pos1 - 1

    ref = str(row["Reference"]).upper()

    if ref not in BASE_TO_I:

        return Evaluation(False, ["non_acgt_reference"], None)


    # 3. Check that REDItools and the reference FASTA agree on the reference

    #    base. Reference mismatches usually indicate build, strand, or input

    #    problems, so they are filtered by default.

    genome_ref = fasta_base(fasta, chrom, pos0)

    if genome_ref is None:

        return Evaluation(False, ["position_out_of_range_or_fasta_fetch_failed"], None)

    if genome_ref != ref and not args.allow_ref_mismatch:

        return Evaluation(False, [f"reference_mismatch_fasta:{genome_ref}_reditools:{ref}"], None)


    rna_counts: List[int] = row["BaseCount[A,C,G,T]"]

    gdna_counts: List[int] = list(row["gBaseCount[A,C,G,T]"])

    # 4. Choose the major non-reference RNA base and quantify editing level.

    alt, alt_count, edit_freq, secondary = major_rna_alt(ref, rna_counts)

    if alt is None:

        return Evaluation(False, ["no_rna_substitution"], None)


    rna_count_depth = sum(rna_counts)

    gdna_count_depth = sum(gdna_counts)

    rna_cov = int(row["Coverage-q30"])

    gdna_cov = int(row["gCoverage-q30"])

    rna_meanq = float(row["MeanQ"])

    gdna_meanq = float(row["gMeanQ"])

    missing_reditools_dna = bool(row.get("_missing_gCoverage_q30") or row.get("_missing_gBaseCount"))

    dna_count_source = "reditools"


    if row.get("_missing_gCoverage_q30") and gdna_count_depth > 0:

        gdna_cov = gdna_count_depth


    if missing_reditools_dna:

        dna_count_source = "missing_reditools_dna"

        if args.use_dna_bam_counts_if_reditools_dna_missing:

            dna_ctx = None

            for bam_ctx in bam_contexts:

                if bam_ctx.label == "dna":

                    dna_ctx = bam_ctx

                    break

            if dna_ctx is not None:

                ok, dna_stats, dna_reason = bam_base_counts_at_site(

                    bam_ctx=dna_ctx,

                    chrom=chrom,

                    pos0=pos0,

                    min_mapq=args.min_mapq,

                    min_baseq=args.dna_variant_min_baseq,

                    max_depth=args.max_pileup_depth,

                    ignore_duplicates=not args.include_duplicates,

                    ignore_qcfail=not args.include_qcfail,

                )

                if ok:

                    gdna_counts = list(dna_stats["counts"])

                    gdna_count_depth = sum(gdna_counts)

                    gdna_cov = int(dna_stats["coverage"])

                    gdna_meanq = float(dna_stats["mean_baseq"])

                    dna_count_source = "dna_bam_fallback"

                else:

                    dna_count_source = "dna_bam_fallback_failed:" + dna_reason



    # 5. Basic evidence filters: enough coverage, enough alternate reads,

    #    sufficient editing fraction, and adequate base quality in RNA/DNA.

    if rna_cov < args.min_rna_coverage:

        reasons.append("low_rna_coverage")

    if gdna_cov < args.min_dna_coverage:

        reasons.append("low_dna_coverage")

    if not math.isnan(rna_meanq) and rna_meanq < args.min_rna_meanq:

        reasons.append("low_rna_mean_base_quality")

    if not math.isnan(gdna_meanq) and gdna_meanq < args.min_dna_meanq:

        reasons.append("low_dna_mean_base_quality")

    if alt_count < args.min_alt_count:

        reasons.append("low_rna_alt_count")

    if edit_freq < args.min_edit_freq:

        reasons.append("low_editing_frequency")


    # 6. Matched-DNA filter: the RNA alternate allele should not be strongly

    #    present in genomic DNA, otherwise the candidate may be a SNP or other

    #    genomic variant rather than an RNA edit.

    alt_i = BASE_TO_I[alt]

    gdna_alt_count = gdna_counts[alt_i]

    gdna_alt_freq = (gdna_alt_count / gdna_count_depth) if gdna_count_depth > 0 else 0.0

    if gdna_alt_freq > args.max_dna_alt_freq:

        reasons.append("high_dna_alt_frequency_possible_snv")

    if gdna_alt_count > args.max_dna_alt_count:

        reasons.append("high_dna_alt_count_possible_snv")


    # 7. Sequence and annotation filters: repeats, homopolymers, splice-site

    #    proximity, and gene-model overlap. These remove common artifact zones.

    repeat_hits = repeats.query_point(chrom, pos0)

    if repeat_hits:

        reasons.append("repeat_overlap")


    hp_len = homopolymer_run_length(fasta, chrom, pos0, args.homopolymer_length)

    if args.homopolymer_length > 1 and hp_len >= args.homopolymer_length:

        reasons.append("homopolymer_run")


    splice_hits = gtf_data.splice_sites.query_point(chrom, pos0) if args.splice_flank > 0 else []

    if splice_hits:

        reasons.append("splice_site_proximity")


    gene_hits = gtf_data.genes.query_point(chrom, pos0)

    if not gene_hits:

        reasons.append("no_gene_model_overlap")


    # If a cheap filter already failed, stop before expensive pileup work.

    if reasons:

        return Evaluation(False, reasons, None)


    # 8. Classify the candidate before mismatch-density filtering so same-type

    #    neighboring edits can be ignored when desired.

    event_group, event_type, event_label, gene_ids, gene_names, gene_strand = classify_event(ref, alt, gene_hits)


    # 9. Pileup-based filters. These are deliberately later because they are

    #    computationally expensive compared with simple table/interval checks.

    mapq_output = {}

    if not args.skip_bam_mapq_filter:

        for bam_ctx in bam_contexts:

            ok, stats, reason = site_mapq_stats(

                bam_ctx=bam_ctx,

                chrom=chrom,

                pos0=pos0,

                min_mapq=args.min_mapq,

                min_mean_mapq=args.min_mean_mapq,

                min_good_frac=args.min_good_mapq_fraction,

                max_depth=args.max_pileup_depth,

                ignore_duplicates=not args.include_duplicates,

                ignore_qcfail=not args.include_qcfail,

            )

            mapq_output[f"{bam_ctx.label}_bam_depth"] = stats.get("depth", "NA")

            mapq_output[f"{bam_ctx.label}_mean_mapq"] = fmt_float(stats.get("mean_mapq", math.nan), 3)

            mapq_output[f"{bam_ctx.label}_good_mapq_fraction"] = fmt_float(stats.get("good_mapq_fraction", math.nan), 3)

            if not ok:

                reasons.append(f"{bam_ctx.label}_{reason}")


    indel_output = {}

    if not args.skip_bam_indel_filter:

        for bam_ctx in bam_contexts:

            ok, stats, reason = indel_stats_in_window(

                bam_ctx=bam_ctx,

                chrom=chrom,

                pos0=pos0,

                flank=args.indel_flank,

                max_indel_frac=args.max_indel_fraction,

                max_indel_reads=args.max_indel_reads,

                max_depth=args.max_pileup_depth,

                ignore_duplicates=not args.include_duplicates,

                ignore_qcfail=not args.include_qcfail,

            )

            indel_output[f"{bam_ctx.label}_max_indel_fraction"] = fmt_float(

                stats.get("max_indel_fraction", math.nan), 3

            )

            indel_output[f"{bam_ctx.label}_max_indel_reads"] = stats.get("max_indel_reads", "NA")

            if not ok:

                reasons.append(f"{bam_ctx.label}_{reason}")


    mismatch_output = {}

    if not args.skip_bam_mismatch_filter:

        for bam_ctx in bam_contexts:

            if args.mismatch_bams != "both" and args.mismatch_bams != bam_ctx.label:

                continue

            ok, stats, reason = mismatch_stats_in_window(

                bam_ctx=bam_ctx,

                fasta=fasta,

                chrom=chrom,

                pos0=pos0,

                flank=args.mismatch_flank,

                min_mapq=args.min_mapq,

                min_baseq=args.mismatch_min_baseq,

                max_window_mismatch_fraction=args.max_window_mismatch_fraction,

                max_column_mismatch_fraction=args.max_column_mismatch_fraction,

                max_high_mismatch_columns=args.max_high_mismatch_columns,

                max_depth=args.max_pileup_depth,

                ignore_duplicates=not args.include_duplicates,

                ignore_qcfail=not args.include_qcfail,

                gene_strand=gene_strand,

                ignore_event_type=event_type,

                count_same_event_mismatches=args.count_same_event_mismatches,

            )

            mismatch_output[f"{bam_ctx.label}_window_mismatch_fraction"] = fmt_float(

                stats.get("window_mismatch_fraction", math.nan), 4

            )

            mismatch_output[f"{bam_ctx.label}_max_mismatch_fraction"] = fmt_float(

                stats.get("max_mismatch_fraction", math.nan), 4

            )

            mismatch_output[f"{bam_ctx.label}_high_mismatch_columns"] = stats.get("high_mismatch_columns", "NA")

            mismatch_output[f"{bam_ctx.label}_mismatch_columns_seen"] = stats.get("mismatch_columns_seen", "NA")

            mismatch_output[f"{bam_ctx.label}_mismatch_bases"] = stats.get("mismatch_bases", "NA")

            mismatch_output[f"{bam_ctx.label}_mismatch_window_bases"] = stats.get("window_bases", "NA")

            if not ok:

                reasons.append(f"{bam_ctx.label}_{reason}")


    if reasons:

        return Evaluation(False, reasons, None)


    # 10. Passed sites are written with both raw evidence and filter-derived

    #     diagnostics so downstream QC does not need to recompute pileups.

    record = {

        "chrom": chrom,

        "position_1based": pos1,

        "position_0based": pos0,

        "reference": ref,

        "genome_reference": genome_ref,

        "alt_base": alt,

        "genomic_edit": f"{ref}>{alt}",

        "transcript_edit": event_type,

        "event_type": event_type,

        "event_group": event_group,

        "event_label": event_label,

        "event_class": event_label,

        "gene_ids": gene_ids,

        "gene_names": gene_names,

        "gene_strand_used": gene_strand,

        "rna_coverage_q30": rna_cov,

        "rna_count_depth": rna_count_depth,

        "rna_alt_count": alt_count,

        "editing_level": fmt_float(edit_freq, 6),

        "secondary_rna_alts": ";".join(secondary) if secondary else "-",

        "rna_mean_base_quality": fmt_float(rna_meanq, 3),

        "dna_coverage_q30": gdna_cov,

        "dna_count_depth": gdna_count_depth,

        "dna_alt_count_same_base": gdna_alt_count,

        "dna_alt_frequency_same_base": fmt_float(gdna_alt_freq, 6),

        "dna_mean_base_quality": fmt_float(gdna_meanq, 3),

        "dna_count_source": dna_count_source,

        "homopolymer_length": hp_len,

        "reditools_allsubs": row.get("AllSubs", "NA"),

        "reditools_frequency": row.get("Frequency", "NA"),

        "reditools_gallsubs": row.get("gAllSubs", "NA"),

        "reditools_gfrequency": row.get("gFrequency", "NA"),

    }

    record.update(mapq_output)

    record.update(indel_output)

    record.update(mismatch_output)

    return Evaluation(True, [], record, event_class=event_label, editing_level=edit_freq, event_group=event_group)




def event_label_sort_key(label: str):

    if label in TRANSCRIPT_SUBSTITUTION_ORDER:

        return (0, TRANSCRIPT_SUBSTITUTION_ORDER.index(label), label)

    if str(label).startswith("ambiguous_"):

        return (2, 0, label)

    return (1, 0, label)



def sorted_event_labels(class_counts: Counter) -> List[str]:

    observed = [label for label, count in class_counts.items() if count > 0]

    return sorted(observed, key=event_label_sort_key)



def _extract_change_from_label(label: str) -> str:

    """Return the substitution pattern embedded in a plot/event label.


    Examples:

      A>G -> A>G

      ambiguous_T>C -> T>C

      genomic_G>A -> G>A

    """

    if not label:

        return ""

    if ">" in label:

        candidate = label.split("_")[-1]

        if len(candidate) == 3 and candidate[1] == ">":

            return candidate

    return label



ADAR_CHANGES = {"A>G", "T>C"}

APOBEC_CHANGES = {"C>T", "G>A"}



def event_group_from_label(label: str) -> str:

    change = _extract_change_from_label(label)

    if change in ADAR_CHANGES:

        return "ADAR"

    if change in APOBEC_CHANGES:

        return "APOBEC"

    return "Other"



def event_color(label: str, args) -> str:

    group = event_group_from_label(label)

    if group == "ADAR":

        return args.adar_color

    if group == "APOBEC":

        return args.apobec_color

    return args.other_color


# -----------------------------------------------------------------------------

# Result figures

# -----------------------------------------------------------------------------

# The bar plot shows how many sites survived in each substitution class. The

# histogram plot shows the editing-level distribution for each class.

def make_bar_plot(class_counts: Counter, out_path: Path, args) -> None:

    import matplotlib


    matplotlib.use("Agg")

    import matplotlib.pyplot as plt


    labels = sorted_event_labels(class_counts)

    if not labels:

        labels = ["no_passed_sites"]

    values = [class_counts.get(label, 0) for label in labels]

    colors = [event_color(label, args) for label in labels]


    fig_width = max(7.0, 0.55 * len(labels) + 3.0)

    fig, ax = plt.subplots(figsize=(fig_width, 4.8))

    bars = ax.bar(labels, values, color=colors, edgecolor="black", linewidth=0.6)

    ax.set_ylabel("Number of filtered editing sites")

    ax.set_title("RNA editing event types")

    ax.set_ylim(0, max(values + [1]) * 1.18)

    ax.tick_params(axis="x", rotation=45)

    for tick in ax.get_xticklabels():

        tick.set_ha("right")

    for bar, value in zip(bars, values):

        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), str(value), ha="center", va="bottom", fontsize=8)

    fig.tight_layout()

    fig.savefig(out_path, dpi=args.plot_dpi)

    plt.close(fig)



def make_histogram_plot(edit_levels: Dict[str, List[float]], out_path: Path, args) -> None:

    import matplotlib


    matplotlib.use("Agg")

    import matplotlib.pyplot as plt


    labels = sorted([label for label, values in edit_levels.items() if values], key=event_label_sort_key)

    if not labels:

        labels = ["no_passed_sites"]

    bins = [i / args.hist_bins for i in range(args.hist_bins + 1)]

    fig_height = max(3.0, 2.45 * len(labels))

    fig, axes = plt.subplots(len(labels), 1, figsize=(7.5, fig_height), sharex=True)

    if len(labels) == 1:

        axes = [axes]

    for ax, label in zip(axes, labels):

        data = edit_levels.get(label, [])

        ax.hist(data, bins=bins, color=event_color(label, args), edgecolor="black", linewidth=0.5)

        ax.set_ylabel(f"{label}\ncount")

        ax.text(0.98, 0.86, f"n={len(data)}", transform=ax.transAxes, ha="right", va="top")

        ax.set_xlim(0, 1)

    axes[-1].set_xlabel("Editing level")

    fig.suptitle("Editing-level distributions by event type", y=0.995)

    fig.tight_layout()

    fig.savefig(out_path, dpi=args.plot_dpi)

    plt.close(fig)



# -----------------------------------------------------------------------------

# Machine-readable summary

# -----------------------------------------------------------------------------

# The summary TSV is intentionally simple: total rows, pass/fail counts, event

# counts, filter-reason counts, and every parameter used in the run.

def write_summary(path: Path, stats: Counter, class_counts: Counter, group_counts: Counter, filter_counts: Counter, args) -> None:

    with open(path, "w", newline="") as handle:

        writer = csv.writer(handle, delimiter="\t")

        writer.writerow(["metric", "value"])

        for key in [

            "input_rows",

            "parse_error",

            "failed_rows",

            "passed_rows",

        ]:

            writer.writerow([key, stats.get(key, 0)])

        writer.writerow(["event_group_counts", ""])

        for label in ["ADAR", "APOBEC", "Other"]:

            writer.writerow([f"event_group_{label}", group_counts.get(label, 0)])

        writer.writerow(["event_type_counts", ""])

        for label in sorted_event_labels(class_counts):

            writer.writerow([f"event_type_{label}", class_counts.get(label, 0)])

        writer.writerow(["filter_reason_counts", ""])

        for reason, count in filter_counts.most_common():

            writer.writerow([f"filter_{reason}", count])

        writer.writerow(["parameters", ""])

        for key, value in sorted(vars(args).items()):

            writer.writerow([f"param_{key}", value])



# -----------------------------------------------------------------------------

# Command-line interface

# -----------------------------------------------------------------------------

# Every biological choice is exposed as a parameter so the methods section can

# report exact thresholds and users can tune the pipeline for different datasets.

def parse_args(argv: Optional[Sequence[str]] = None):

    parser = argparse.ArgumentParser(

        description="Filter REDItools candidate RNA editing sites and classify exact event types with ADAR/APOBEC grouping.",

        formatter_class=argparse.ArgumentDefaultsHelpFormatter,

    )

    required = parser.add_argument_group("required inputs")

    required.add_argument("--reditools", required=True, help="REDItools output table. Use '-' for stdin.")

    required.add_argument("--repeatmasker", required=True, help="RepeatMasker .out, BED, or GFF-like repeat intervals.")

    required.add_argument("--genome", required=True, help="Indexed genome FASTA.")

    required.add_argument("--rna-bam", required=True, help="Indexed RNA-seq BAM.")

    required.add_argument("--dna-bam", required=True, help="Indexed DNA-seq BAM.")

    required.add_argument("--gtf", required=True, help="GTF annotation file.")

    required.add_argument("--out-prefix", required=True, help="Output prefix, for example results/sample.")


    parser.add_argument("--repeat-format", choices=["auto", "rmout", "repeatmasker", "bed", "gff"], default="auto", help="Repeat interval format. Use rmout/repeatmasker for standard RepeatMasker .out files.")

    parser.add_argument("--overlap-feature", default="gene", help="GTF feature used for gene-model overlap. Falls back to exon spans if absent.")


    filters = parser.add_argument_group("site filters")

    filters.add_argument("--min-rna-coverage", type=int, default=10)

    filters.add_argument("--min-dna-coverage", type=int, default=10)

    filters.add_argument("--min-rna-meanq", type=float, default=25.0, help="Minimum REDItools RNA MeanQ.")

    filters.add_argument("--min-dna-meanq", type=float, default=25.0, help="Minimum REDItools DNA gMeanQ.")

    filters.add_argument("--use-dna-bam-counts-if-reditools-dna-missing", action="store_true", help="When REDItools genomic DNA fields are '-', count DNA bases directly from the DNA BAM instead of treating DNA evidence as zero coverage.")

    filters.add_argument("--dna-variant-min-baseq", type=int, default=20, help="Minimum DNA BAM base quality used by --use-dna-bam-counts-if-reditools-dna-missing.")

    filters.add_argument("--min-alt-count", type=int, default=3)

    filters.add_argument("--min-edit-freq", type=float, default=0.01)

    filters.add_argument("--max-dna-alt-freq", type=float, default=0.05)

    filters.add_argument("--max-dna-alt-count", type=int, default=3)

    filters.add_argument("--homopolymer-length", type=int, default=5, help="Exclude sites inside same-base runs at least this long. Use 0 to disable.")

    filters.add_argument("--splice-flank", type=int, default=2, help="Exclude positions within this many bases of exon junction boundaries. Use 0 to disable.")

    filters.add_argument("--allow-ref-mismatch", action="store_true", help="Do not fail sites when REDItools reference differs from FASTA.")


    bam_filters = parser.add_argument_group("BAM mapping and indel filters")

    bam_filters.add_argument("--min-mapq", type=int, default=30, help="Per-read MAPQ threshold used to compute good MAPQ fraction.")

    bam_filters.add_argument("--min-mean-mapq", type=float, default=30.0, help="Minimum mean MAPQ at the site.")

    bam_filters.add_argument("--min-good-mapq-fraction", type=float, default=0.80, help="Minimum fraction of usable reads with MAPQ >= min-mapq.")

    bam_filters.add_argument("--indel-flank", type=int, default=5, help="Check indels in this many bases on each side of the site.")

    bam_filters.add_argument("--max-indel-fraction", type=float, default=0.02, help="Maximum allowed indel-read fraction at any flanking pileup column.")

    bam_filters.add_argument("--max-indel-reads", type=int, default=2, help="Maximum allowed indel-supporting reads at any flanking pileup column.")

    bam_filters.add_argument("--max-pileup-depth", type=int, default=100000)

    bam_filters.add_argument("--include-duplicates", action="store_true", help="Include duplicate reads in BAM pileup filters.")

    bam_filters.add_argument("--include-qcfail", action="store_true", help="Include QC-fail reads in BAM pileup filters.")

    bam_filters.add_argument("--skip-bam-mapq-filter", action="store_true", help="Skip mapping-quality checks from BAMs.")

    bam_filters.add_argument("--skip-bam-indel-filter", action="store_true", help="Skip flanking-indel checks from BAMs.")

    bam_filters.add_argument("--mismatch-flank", type=int, default=10, help="Check local reference-discordant mismatch density this many bases on each side of the candidate. The candidate base itself is excluded.")

    bam_filters.add_argument("--mismatch-bams", choices=["rna", "dna", "both"], default="rna", help="Which BAMs to use for the local mismatch-density filter. RNA is usually most useful for IGV red-read artefacts.")

    bam_filters.add_argument("--mismatch-min-baseq", type=int, default=20, help="Minimum base quality used by the local mismatch-density filter.")

    bam_filters.add_argument("--max-window-mismatch-fraction", type=float, default=0.08, help="Maximum total mismatch fraction across the flanking window.")

    bam_filters.add_argument("--max-column-mismatch-fraction", type=float, default=0.30, help="Maximum mismatch fraction allowed at any one flanking reference column.")

    bam_filters.add_argument("--max-high-mismatch-columns", type=int, default=2, help="Maximum number of flanking columns whose mismatch fraction is above --max-column-mismatch-fraction.")

    bam_filters.add_argument("--skip-bam-mismatch-filter", action="store_true", help="Skip local mismatch-density checks for possible mismapped reads.")

    bam_filters.add_argument("--count-same-event-mismatches", action="store_true", help="Count nearby mismatches that match the candidate's transcript-oriented edit type in the local mismatch-density filter. By default these same-type changes are ignored to avoid penalizing real edited clusters.")


    output = parser.add_argument_group("output and plots")

    output.add_argument("--plot-format", choices=["png", "pdf", "svg"], default="png")

    output.add_argument("--plot-dpi", type=int, default=180)

    output.add_argument("--hist-bins", type=int, default=20)

    output.add_argument("--adar-color", default="#3B82F6")

    output.add_argument("--apobec-color", default="#F97316")

    output.add_argument("--other-color", default="#9CA3AF")

    output.add_argument("--write-failures", action="store_true", help="Write a TSV of filtered-out sites and their reasons. This can be large.")

    output.add_argument("--max-sites", type=int, default=0, help="Process at most this many REDItools rows. Use 0 for all rows.")

    output.add_argument("--progress-every", type=int, default=100000, help="Log progress every N rows. Use 0 to disable.")

    output.add_argument("--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"], default="INFO")


    args = parser.parse_args(argv)

    if args.homopolymer_length < 0:

        parser.error("--homopolymer-length cannot be negative")

    if args.splice_flank < 0:

        parser.error("--splice-flank cannot be negative")

    if args.hist_bins < 1:

        parser.error("--hist-bins must be positive")

    if not (0 <= args.min_edit_freq <= 1):

        parser.error("--min-edit-freq must be between 0 and 1")

    if not (0 <= args.max_dna_alt_freq <= 1):

        parser.error("--max-dna-alt-freq must be between 0 and 1")

    if args.dna_variant_min_baseq < 0:

        parser.error("--dna-variant-min-baseq must be non-negative")

    if not (0 <= args.max_indel_fraction <= 1):

        parser.error("--max-indel-fraction must be between 0 and 1")

    if not (0 <= args.min_good_mapq_fraction <= 1):

        parser.error("--min-good-mapq-fraction must be between 0 and 1")

    if args.mismatch_flank < 0:

        parser.error("--mismatch-flank cannot be negative")

    if args.mismatch_min_baseq < 0:

        parser.error("--mismatch-min-baseq must be non-negative")

    if not (0 <= args.max_window_mismatch_fraction <= 1):

        parser.error("--max-window-mismatch-fraction must be between 0 and 1")

    if not (0 <= args.max_column_mismatch_fraction <= 1):

        parser.error("--max-column-mismatch-fraction must be between 0 and 1")

    if args.max_high_mismatch_columns < 0:

        parser.error("--max-high-mismatch-columns cannot be negative")

    return args



# -----------------------------------------------------------------------------

# Pipeline driver

# -----------------------------------------------------------------------------

# run() validates inputs, loads annotations, streams candidate rows, writes the

# filtered table, accumulates summary counters, and generates the result figures.

def run(args) -> None:

    validate_file(args.reditools, "REDItools input", allow_stdin=True)

    validate_file(args.repeatmasker, "RepeatMasker input")

    validate_file(args.genome, "genome FASTA")

    validate_file(args.rna_bam, "RNA BAM")

    validate_file(args.dna_bam, "DNA BAM")

    validate_file(args.gtf, "GTF annotation")


    out_prefix = Path(args.out_prefix)

    out_prefix.parent.mkdir(parents=True, exist_ok=True)


    fasta = open_fasta(args.genome)

    fasta_resolver = ContigResolver(fasta.references)

    rna_bam = open_bam(args.rna_bam, "RNA")

    dna_bam = open_bam(args.dna_bam, "DNA")

    bam_contexts = [

        BamContext("rna", rna_bam, ContigResolver(rna_bam.references)),

        BamContext("dna", dna_bam, ContigResolver(dna_bam.references)),

    ]


    logging.info("Loading GTF annotation")

    gtf_data = load_gtf(args.gtf, fasta_resolver, args.overlap_feature, args.splice_flank)

    logging.info(

        "Loaded %s gene intervals and %s splice-site intervals",

        gtf_data.loaded_gene_intervals,

        gtf_data.loaded_splice_intervals,

    )

    logging.info("Loading repeats")

    repeats = load_repeats(args.repeatmasker, fasta_resolver, args.repeat_format)


    passed_path = out_prefix.with_suffix(".filtered_edits.tsv")

    summary_path = out_prefix.with_suffix(".summary.tsv")

    failures_path = out_prefix.with_suffix(".filtered_out_sites.tsv")

    bar_path = out_prefix.with_suffix(f".event_class_counts.{args.plot_format}")

    hist_path = out_prefix.with_suffix(f".editing_level_histograms.{args.plot_format}")


    stats = Counter()

    filter_counts = Counter()

    class_counts = Counter()

    group_counts = Counter()

    edit_levels: Dict[str, List[float]] = defaultdict(list)


    output_fields = [

        "chrom",

        "position_1based",

        "position_0based",

        "reference",

        "genome_reference",

        "alt_base",

        "genomic_edit",

        "transcript_edit",

        "event_type",

        "event_group",

        "event_label",

        "event_class",

        "gene_ids",

        "gene_names",

        "gene_strand_used",

        "rna_coverage_q30",

        "rna_count_depth",

        "rna_alt_count",

        "editing_level",

        "secondary_rna_alts",

        "rna_mean_base_quality",

        "dna_coverage_q30",

        "dna_count_depth",

        "dna_alt_count_same_base",

        "dna_alt_frequency_same_base",

        "dna_mean_base_quality",

        "dna_count_source",

        "homopolymer_length",

        "rna_bam_depth",

        "rna_mean_mapq",

        "rna_good_mapq_fraction",

        "dna_bam_depth",

        "dna_mean_mapq",

        "dna_good_mapq_fraction",

        "rna_max_indel_fraction",

        "rna_max_indel_reads",

        "dna_max_indel_fraction",

        "dna_max_indel_reads",

        "rna_window_mismatch_fraction",

        "rna_max_mismatch_fraction",

        "rna_high_mismatch_columns",

        "rna_mismatch_columns_seen",

        "rna_mismatch_bases",

        "rna_mismatch_window_bases",

        "dna_window_mismatch_fraction",

        "dna_max_mismatch_fraction",

        "dna_high_mismatch_columns",

        "dna_mismatch_columns_seen",

        "dna_mismatch_bases",

        "dna_mismatch_window_bases",

        "reditools_allsubs",

        "reditools_frequency",

        "reditools_gallsubs",

        "reditools_gfrequency",

    ]


    failure_fields = ["line_no", "chrom", "position_1based", "reference", "filter_reasons"]


    with open(passed_path, "w", newline="") as passed_handle:

        passed_writer = csv.DictWriter(passed_handle, delimiter="\t", fieldnames=output_fields, extrasaction="ignore")

        passed_writer.writeheader()


        failure_handle = None

        failure_writer = None

        if args.write_failures:

            failure_handle = open(failures_path, "w", newline="")

            failure_writer = csv.DictWriter(failure_handle, delimiter="\t", fieldnames=failure_fields, extrasaction="ignore")

            failure_writer.writeheader()


        try:

            for line_no, row in parse_reditools(args.reditools):

                stats["input_rows"] += 1

                if args.max_sites and stats["input_rows"] > args.max_sites:

                    break

                if args.progress_every and stats["input_rows"] % args.progress_every == 0:

                    logging.info(

                        "Processed %s rows, passed %s",

                        stats["input_rows"],

                        stats["passed_rows"],

                    )

                evaluation = evaluate_site(

                    row=row,

                    line_no=line_no,

                    fasta=fasta,

                    fasta_resolver=fasta_resolver,

                    repeats=repeats,

                    gtf_data=gtf_data,

                    bam_contexts=bam_contexts,

                    args=args,

                )

                if evaluation.passed and evaluation.record:

                    passed_writer.writerow(evaluation.record)

                    stats["passed_rows"] += 1

                    class_counts[evaluation.event_class or "Other"] += 1

                    group_counts[evaluation.event_group or event_group_from_label(evaluation.event_class or "Other")] += 1

                    if evaluation.editing_level is not None:

                        edit_levels[evaluation.event_class or "Other"].append(evaluation.editing_level)

                else:

                    stats["failed_rows"] += 1

                    if "parse_error" in evaluation.reasons:

                        stats["parse_error"] += 1

                    filter_counts.update(evaluation.reasons)

                    if failure_writer is not None:

                        failure_writer.writerow(

                            {

                                "line_no": line_no,

                                "chrom": row.get("Region", "NA"),

                                "position_1based": row.get("Position", "NA"),

                                "reference": row.get("Reference", "NA"),

                                "filter_reasons": ";".join(evaluation.reasons),

                            }

                        )

        finally:

            if failure_handle is not None:

                failure_handle.close()

            fasta.close()

            rna_bam.close()

            dna_bam.close()


    write_summary(summary_path, stats, class_counts, group_counts, filter_counts, args)

    make_bar_plot(class_counts, bar_path, args)

    make_histogram_plot(edit_levels, hist_path, args)


    logging.info("Wrote filtered edits: %s", passed_path)

    logging.info("Wrote summary: %s", summary_path)

    if args.write_failures:

        logging.info("Wrote filtered-out sites: %s", failures_path)

    logging.info("Wrote plot: %s", bar_path)

    logging.info("Wrote plot: %s", hist_path)



def main(argv: Optional[Sequence[str]] = None) -> int:

    args = parse_args(argv)

    logging.basicConfig(

        level=getattr(logging, args.log_level),

        format="[%(asctime)s] %(levelname)s: %(message)s",

        datefmt="%Y-%m-%d %H:%M:%S",

    )

    try:

        run(args)

    except KeyboardInterrupt:

        logging.error("Interrupted by user.")

        return 130

    except EditPipelineError as exc:

        logging.error("%s", exc)

        return 2

    except BrokenPipeError:

        return 1

    except Exception as exc:

        logging.exception("Unexpected error: %s", exc)

        return 1

    return 0



if __name__ == "__main__":

    raise SystemExit(main())


```

</details>