Skip to main content

ferro_hgvs/normalize/
from_sequences.rs

1//! Derive an HGVS description from a reference/alternate sequence pair.
2//!
3//! The caller supplies the bases; this module supplies the description. It reads
4//! **no reference sequence**, so its output is a pure function of its inputs —
5//! which is what makes it deterministic in the sense a BAM post-processor needs:
6//! the same bases give the same description on any machine, against any
7//! reference build, with no hidden input.
8//!
9//! "Its inputs" is five values, not four, and the fifth is not inert: the
10//! accession, the position, the two sequences **and** the
11//! [`FromSequencesOptions`] — whose `direction` moves a placement within the
12//! window and whose `max_grid_cells` decides whether an answer is produced at
13//! all. Purity is the claim; a four-argument count was never accurate and is
14//! withdrawn wherever it was written.
15//!
16//! # Case is folded, and the axis is DNA
17//!
18//! Both sequences are upper-cased before anything reads them. A soft-masked
19//! reference arrives lower-case (`fetch_window` does not case-fold), while a
20//! rendered payload is always upper-case, so without folding a masked window
21//! aligns against its own payload as if the two disagreed — and the round trip
22//! below then refuses a derivation that was correct. Folding once, at the
23//! entry, means every later stage sees one alphabet.
24//!
25//! `U` is refused rather than folded to `T`. This surface emits `g.`/`m.`
26//! descriptions, which are DNA; admitting `U` produced `NC_TEST.1:g.8U>T`,
27//! a well-formed string naming a base its own axis does not have.
28//!
29//! # What it owns, and what it does not
30//!
31//! `README.md`'s four normalization rules split cleanly here. This module
32//! delivers rules **1 (conformant)** and **4 (deterministic)** — the two the
33//! README calls always achievable. Rules **2 (recommended form)** and
34//! **3 (confluent)** stay with [`crate::Normalizer::normalize`], because both
35//! need the reference: rule 2's scope names the 3' rule explicitly, and a
36//! reference-anchored shift is precisely what a window-local function cannot do.
37//!
38//! So an output here may be 3'-shiftable further than this module shifted it,
39//! and that is not a defect. Run `normalize` afterwards if you want it.
40//!
41//! # Why it partitions at all
42//!
43//! Strictly it need not: a single spanning `delins` is a legal description, so
44//! this module could emit one and leave any split to `normalize`. It cannot
45//! today — handed a spanning `delins`, the shipped normalizer leaves it alone,
46//! because [`super::merge::partition_block`] searches single-gap alignments only
47//! and the weight bound then refuses the re-derivation. Measured over two 1 bp
48//! deletions swept across separations 0-20 on `NC_000001.11:g.1000050`, 17 of
49//! the 21 separations come back as one spanning `delins`.
50//!
51//! **That is a workaround and not the design.** When #1419 / #1420 / #1421 /
52//! #1440 land, `normalize` will re-derive over a spanning `delins` and this
53//! reason expires. The partitioning stays, because a partition derived from the
54//! sequence is what this module is for; the justification above should not be
55//! mistaken for a permanent one.
56//!
57//! # Why the alignment DAG, and not an aligner
58//!
59//! [`crate::normalize::seqfirst::align::AlignmentDag`] holds **every**
60//! minimal-cost alignment at once, and `CanonicalAlignment` takes the
61//! member-count-minimal one, 3'-most among ties. The tie-break is
62//! `general.md:41`, an explicit spec tie-break, which the
63//! `canonical-form-choice-when-both-legal` ruling requires be applied.
64//!
65//! Affine-gap Needleman-Wunsch was implemented and measured against it over
66//! 28,639 synthetic two-event blocks at separations 0-12. bwa-mem's defaults
67//! (`A=1 B=4 O=6 E=1`) return an alignment above the block's edit distance on
68//! **6.7%** of them, at up to double the necessary changed columns, because a
69//! mismatch costs 4 where a gap-extend costs 1 — so it prefers deleting and
70//! reinserting a run to substituting it. minimap2's dual-affine `lr:hq` scheme
71//! reaches 2.2%. `CanonicalAlignment` is 0 of 28,639, not because it is better
72//! tuned but because every path it walks is distance-minimal by construction.
73//! Both the scoring matrix and the traceback order are free parameters and both
74//! are visible in the output; this has neither.
75//!
76//! **Distance-minimality is ferro's policy, not the spec's.** `basics.md:38`
77//! lists stability, meaning, memorability and unequivocality and does not
78//! mention minimality at all; `DNA/delins.md:44-47` recommends a *non-minimal*
79//! description in its own worked example. Cite this module's own reasoning for
80//! it, never the recommendations.
81
82use crate::error::FerroError;
83use crate::hgvs::edit::{InsertedSequence, NaEdit};
84use crate::hgvs::interval::UncertainBoundary;
85use crate::hgvs::uncertainty::Mu;
86use crate::hgvs::variant::{AllelePhase, AlleleVariant, HgvsVariant};
87use crate::normalize::merge::{
88    denoted_bases, derive_block_members, BlockDecline, MAX_SEQFIRST_GRID_CELLS,
89};
90use crate::normalize::ShuffleDirection;
91
92/// Cost knobs for [`from_sequences`].
93///
94/// Nothing here changes which *forms* the function is willing to emit — that is
95/// fixed by the rules, not by the caller (`README.md` rule 6). `max_grid_cells`
96/// is a memory bound. `direction` is **not** a caller-facing knob: it mirrors
97/// `NormalizeConfig`'s internal test instrument, is `#[doc(hidden)]` for the
98/// same reason, and is always `ThreePrime` on every shipped path.
99#[derive(Debug, Clone, Copy)]
100#[non_exhaustive]
101pub struct FromSequencesOptions {
102    /// Largest alignment grid, in cells, the partitioner will build.
103    ///
104    /// A cell costs roughly **18 bytes** on this arm, so the default —
105    /// `(4096 + 1)^2`, about 16.8 M cells — admits roughly **310 MB** at the
106    /// limit. Lower it when reads are short and the budget matters; raise it for
107    /// a long-read haplotype, having read that figure. Exceeding it **refuses**:
108    /// this is a cost bound, and refusing rather than silently answering with a
109    /// weaker rule is the policy.
110    pub max_grid_cells: usize,
111    /// Which end of an ambiguous run a pure indel is placed at, within the
112    /// caller's window. Always `ThreePrime`, which matches `general.md:41`.
113    ///
114    /// **Internal test instrument, not a supported knob** — see
115    /// [`crate::normalize::ShuffleDirection`].
116    #[doc(hidden)]
117    pub direction: ShuffleDirection,
118}
119
120impl Default for FromSequencesOptions {
121    fn default() -> Self {
122        Self {
123            max_grid_cells: MAX_SEQFIRST_GRID_CELLS,
124            direction: ShuffleDirection::ThreePrime,
125        }
126    }
127}
128
129/// The two setters exist because the struct is `#[non_exhaustive]`, which
130/// forbids a struct expression **outside this crate** — so without them a
131/// downstream caller can reach `Default::default()` and nothing else, and the
132/// knobs are documented but unreachable. The bug was found by moving one test
133/// into `tests/it`, which is a separate crate and so sees the same surface a
134/// user does.
135///
136/// Named after [`crate::NormalizeConfig::with_direction`], which is the same
137/// pattern for the same reason.
138impl FromSequencesOptions {
139    /// Set which end of an ambiguous run a pure indel is placed at.
140    ///
141    /// **Internal test instrument, not a supported knob** — see
142    /// [`crate::normalize::ShuffleDirection`].
143    #[doc(hidden)]
144    #[must_use]
145    pub fn with_direction(mut self, direction: ShuffleDirection) -> Self {
146        self.direction = direction;
147        self
148    }
149
150    /// Set the alignment-grid budget, in cells. See
151    /// [`FromSequencesOptions::max_grid_cells`] for what a cell costs.
152    #[must_use]
153    pub fn with_max_grid_cells(mut self, max_grid_cells: usize) -> Self {
154        self.max_grid_cells = max_grid_cells;
155        self
156    }
157}
158
159/// A derived description, plus the one caveat a window-local derivation owes its
160/// caller.
161#[derive(Debug, Clone)]
162#[non_exhaustive]
163pub struct DerivedDescription {
164    /// The description.
165    pub variant: HgvsVariant,
166    /// Whether a member rests on the window's **5' edge**.
167    ///
168    /// Ask [`Self::placement_bounded_by_window`] for the plain "could this move
169    /// at all" answer; this and [`Self::bounded_at_end`] say *which* side, which
170    /// is what a caller widening one side at a time needs.
171    pub bounded_at_start: bool,
172    /// Whether a member rests on the window's **3' edge**.
173    ///
174    /// The 3' counterpart of [`Self::bounded_at_start`]; see it.
175    pub bounded_at_end: bool,
176}
177
178impl DerivedDescription {
179    /// Whether a member's placement was bounded by the window rather than
180    /// settled by the sequence — i.e. it rests on *either* edge of the bases
181    /// supplied. The OR of [`Self::bounded_at_start`] and
182    /// [`Self::bounded_at_end`].
183    ///
184    /// **This is a "could move" flag, not a "is wrong" flag**, and it is
185    /// deliberately conservative in that direction. Distinguishing a placement
186    /// that merely *reaches* the edge from one that would have gone *past* it
187    /// requires knowing what lies outside the window — the reference — which
188    /// this function does not read. So it reports the uncertainty rather than
189    /// resolving it.
190    ///
191    /// And note what a flagged answer is *not*: it is never wrong. `g.14del`
192    /// and `g.15del` over that run denote the same bases and share a canonical
193    /// SPDI. What a clipped placement costs is the **recommended form** (rule 2)
194    /// and **agreement with a wider read** (rule 3) — the two rules this
195    /// function never claimed, because both need the reference. Rules 1 and 4
196    /// hold regardless.
197    ///
198    /// The condition under which the flag stops mattering is exact: two windows
199    /// that both contain the whole interval over which the change can be placed
200    /// derive the same description, and a window that cuts that interval places
201    /// the change at its own edge instead.
202    #[must_use]
203    pub fn placement_bounded_by_window(&self) -> bool {
204        self.bounded_at_start || self.bounded_at_end
205    }
206}
207
208/// Derive an HGVS description from a reference/alternate sequence pair.
209///
210/// `position` is **1-based** and names the first base of `reference`, matching
211/// `VcfRecord` and HGVS.
212///
213/// `reference` is taken on trust to be the reference bases over
214/// `[position, position + reference.len())`. It is **not** verified, because
215/// verifying it would need the reference and would make the provider a hidden
216/// fifth input — which would cost exactly the determinism this function exists
217/// to provide. Pass bases that are not the reference and you get a faithful
218/// description of the pair you passed.
219///
220/// See [`crate::Normalizer::from_sequences`] for the sibling that does hold a
221/// provider, and so can additionally refuse an unknown accession or an interval
222/// running past the end of the sequence.
223///
224/// # Errors
225///
226/// Refuses a zero `position`, an empty `reference`, a non-nucleotide symbol in
227/// either sequence, an alignment grid over `options.max_grid_cells`, a partition
228/// that will not render, and — as a runtime check, never a debug assertion — a
229/// derivation that does not re-apply to `alternate`.
230pub fn from_sequences(
231    accession: &str,
232    position: u64,
233    reference: &str,
234    alternate: &str,
235    options: &FromSequencesOptions,
236) -> Result<HgvsVariant, FerroError> {
237    from_sequences_detailed(accession, position, reference, alternate, options)
238        .map(|derived| derived.variant)
239}
240
241/// [`from_sequences`], reporting also whether the derivation reached a window
242/// edge. See [`DerivedDescription::placement_bounded_by_window`].
243pub fn from_sequences_detailed(
244    accession: &str,
245    position: u64,
246    reference: &str,
247    alternate: &str,
248    options: &FromSequencesOptions,
249) -> Result<DerivedDescription, FerroError> {
250    validate(position, reference.as_bytes(), alternate.as_bytes())?;
251    // Folded **after** validation, so a refusal quotes the symbol the caller
252    // actually passed rather than an upper-cased rewrite of it. See the module
253    // docs for why folding happens at all.
254    let (reference, alternate) = (
255        reference.to_ascii_uppercase().into_bytes(),
256        alternate.to_ascii_uppercase().into_bytes(),
257    );
258    let (reference, alternate) = (reference.as_slice(), alternate.as_slice());
259
260    let template = reference_template(accession)?;
261    let w_lo = i64::try_from(position).map_err(|_| FerroError::InvalidCoordinates {
262        msg: format!("position {position} does not fit a signed coordinate"),
263    })?;
264
265    // This surface's block partitioner is a **pin**, not `FERRO_PARTITION`'s
266    // reading: `merge::DERIVED_BLOCK_PARTITION_RULE` names it, and
267    // `merge::partition_block_for_derivation` is where it is consulted. So a
268    // change to the rule `Normalizer::normalize` cuts with does not reach here,
269    // which is #1834 — the tripwire that fails when either moves alone is
270    // `merge`'s `the_two_surfaces_cut_with_a_pinned_pair_of_rules`.
271    let block = derive_block_members(
272        reference,
273        alternate,
274        &template,
275        w_lo,
276        options.direction,
277        options.max_grid_cells,
278    )
279    .map_err(|decline| match decline {
280        BlockDecline::GridTooLarge { ref_len, alt_len } => {
281            grid_refusal(ref_len, alt_len, options.max_grid_cells)
282        }
283        BlockDecline::WouldNotRender => FerroError::ConversionError {
284            msg: format!(
285                "could not render the derived partition of {accession}:{position} as HGVS \
286                 members"
287            ),
288        },
289    })?;
290
291    if block.anchors_before_window {
292        return Err(five_prime_anchor_refusal(accession, position));
293    }
294
295    let mut members = block.members;
296    retype_inversions(&mut members, reference, w_lo);
297    // Split any equal-length spanning `delins` the place chain merged into its
298    // canonical [sub; inv] decomposition, as `normalize` does per member. Runs
299    // after `retype_inversions` (whole-span inv) since the two are complementary
300    // — see `split_canonical_delins`.
301    let members = split_canonical_delins(members, reference, w_lo);
302
303    let variant = match members.len() {
304        0 => template,
305        1 => members.into_iter().next().expect("length checked"),
306        _ => HgvsVariant::Allele(AlleleVariant::new(members, AllelePhase::Cis)),
307    };
308
309    verify_round_trip(&variant, w_lo, reference, alternate)?;
310
311    Ok(DerivedDescription {
312        variant,
313        bounded_at_start: block.bounded_at_start,
314        bounded_at_end: block.bounded_at_end,
315    })
316}
317
318/// The refusal for a pure insertion resting on the window's 5' edge.
319///
320/// See [`crate::normalize::merge::DerivedBlock::anchors_before_window`] for why
321/// this cannot be answered rather than refused: the only HGVS spelling of such a
322/// payload names `position - 1`, which the caller supplied no bases for and
323/// which does not exist at all when `position` is 1.
324///
325/// Named as its own refusal rather than left to `verify_round_trip`, which
326/// catches it as a side effect and reports it as an internal invariant failure
327/// ("could not be re-applied to its window") — true, unactionable, and a
328/// misattribution of a caller-fixable problem to ferro. Found by running the
329/// #1419/#1420/#1421 reported pairs at a zero pad, where `g.[37dup;41del]`
330/// derives to `g.[37_38insA;41del]` against a window starting at 38.
331///
332/// **How much flank is enough depends on the direction**, which is why the
333/// message says "more" rather than naming a number. One base suffices for the
334/// row above under `ThreePrime`. Under `FivePrime` the same payload is placed
335/// 5'-most, so it walks to the start of whatever ambiguous run it sits in and
336/// needs enough flank to clear it — the same row still refuses at a pad of one
337/// and derives at six. Any fixed figure here would be wrong for one of the two
338/// directions.
339fn five_prime_anchor_refusal(accession: &str, position: u64) -> FerroError {
340    FerroError::InvalidCoordinates {
341        msg: format!(
342            "{accession}:{position} — the derivation places an inserted payload immediately \
343             5' of the window's first base, so its only HGVS anchor is position {} (an \
344             insertion is written between the two positions it falls between), which is \
345             outside the window supplied. Supply more 5' flank — \
346             `Normalizer::to_sequences` pads both sides, and how much is enough depends on \
347             the shuffle direction, since a 5'-most placement walks to the start of an \
348             ambiguous run",
349            position.saturating_sub(1)
350        ),
351    }
352}
353
354/// Reject the inputs no description can be derived from.
355///
356/// Deliberately short. Range-checking `position` against the sequence needs the
357/// reference, which this path does not hold; that check belongs to
358/// [`crate::Normalizer::from_sequences`] and is documented as living there.
359///
360/// Shared with [`crate::SequencePair::new`] rather than copied into it. The two
361/// entry points accept the same four values and must accept exactly the same
362/// ones — a constructor that admitted a pair `from_sequences` would later refuse
363/// would just move the error somewhere less useful.
364///
365/// Runs on the caller's own bytes, before the upper-casing
366/// [`from_sequences_detailed`] applies, so a message quotes what was passed.
367/// Case itself is not a reason to refuse: `Base::from_char` folds, and a
368/// soft-masked window is ordinary input.
369pub(crate) fn validate(
370    position: u64,
371    reference: &[u8],
372    alternate: &[u8],
373) -> Result<(), FerroError> {
374    if position == 0 {
375        return Err(FerroError::InvalidCoordinates {
376            msg: "position is 1-based; 0 does not name a base".to_string(),
377        });
378    }
379    if reference.is_empty() {
380        return Err(FerroError::InvalidCoordinates {
381            msg: "reference is empty, so there is no interval to describe".to_string(),
382        });
383    }
384    // The alphabet is `Base::from_char`'s — the codebase's own IUPAC-IUBMB
385    // table — rather than a list local to this function.
386    //
387    // It was `ACGTN` at first, which is stricter than the spec and stricter
388    // than real data. `general.md:48` admits the IUPAC-IUBMB symbol set, and
389    // the `alignment-only-symbol-in-a-description` ruling cites `:48` for that
390    // scope while excluding only `X` and `-`; ambiguity codes are in. Measured
391    // against the harvested ClinVar/CMRG/Paraphase multi-member alleles, the
392    // narrow alphabet refused real submitted rows outright —
393    // `NM_000518.4:c.[20A>T;249G>Y]` among them.
394    //
395    // `X` and `-` are still refused, because `from_char` does not admit them.
396    //
397    // `U` is refused too, and that exclusion is this surface's own rather than
398    // `from_char`'s. `from_char` admits `U` because it also serves the `r.`
399    // axis; this surface emits `g.` and `m.` descriptions, which are DNA. It was
400    // admitted asymmetrically before: `ACGU` -> `ACGT` derived
401    // `NC_TEST.1:g.8U>T`, a well-formed string naming a base the axis does not
402    // have, while `ACGT` -> `AUGT` refused with the round trip's internal
403    // "denotes different bases" message — a caller-fixable input reported as an
404    // invariant failure. One refusal, stated at the argument, replaces both.
405    for (label, bases) in [("reference", reference), ("alternate", alternate)] {
406        if let Some(bad) = bases
407            .iter()
408            .find(|b| crate::hgvs::edit::Base::from_char(**b as char).is_none())
409        {
410            return Err(FerroError::ConversionError {
411                msg: format!(
412                    "{label} contains '{}', which is not a nucleotide — standards.md:39 admits \
413                     no alignment-only symbol in a description; pass IUPAC-IUBMB nucleotide \
414                     codes (general.md:48)",
415                    *bad as char
416                ),
417            });
418        }
419        if bases.iter().any(|b| b.eq_ignore_ascii_case(&b'U')) {
420            return Err(FerroError::ConversionError {
421                msg: format!(
422                    "{label} contains 'U', which is RNA; this surface derives g./m. \
423                     descriptions, whose axis is DNA. Pass 'T' instead, or project onto an r. \
424                     axis afterwards"
425                ),
426            });
427        }
428    }
429    Ok(())
430}
431
432/// The refusal for a block whose alignment grid exceeds the budget.
433///
434/// Names the knob and its per-cell cost, so the caller can decide rather than
435/// guess. A refusal that does not say how to proceed is a dead end.
436fn grid_refusal(ref_len: usize, alt_len: usize, budget: usize) -> FerroError {
437    FerroError::ConversionError {
438        msg: format!(
439            "alignment grid for a {ref_len} x {alt_len} window exceeds max_grid_cells \
440             ({budget}); raise FromSequencesOptions::max_grid_cells — about 18 bytes per cell — \
441             or narrow the window"
442        ),
443    }
444}
445
446/// A minimal `g.` (or `m.`) variant carrying only the accession, for
447/// [`super::merge::rebuild_members`]' template argument and as the identity
448/// description.
449///
450/// Built by parsing rather than by constructing the types directly: `g.=` is the
451/// identity description this function must return for an unchanged pair anyway,
452/// and `build_merged` reads only the accession and gene symbol off a template,
453/// so one parse serves both jobs and neither depends on internal constructors.
454///
455/// # Why the accession is classified before the template is built
456///
457/// The classification used to run *after* `parse_hgvs("{accession}:g.=")`, which
458/// made it unreachable for exactly the accessions that most need it: the parser
459/// refuses a `g.` axis on an `NR_`/`XR_` outright (`Accession::is_noncoding_rna`
460/// — #486), so `NR_000001.1` fell out of the parse arm with the generic
461/// "'NR_000001.1' is not a usable genomic accession", which reads as "ferro does
462/// not know this accession" rather than "this is a non-coding transcript, use
463/// `n.`". Classifying first gives every non-genomic class the same informative
464/// refusal.
465///
466/// # The axis is `m.` on a mitochondrial accession
467///
468/// `Accession::is_mitochondrial` is the codebase's own predicate, and its doc
469/// states the rule: "HGVS requires the `m.` coordinate system for these
470/// accessions, so `normalize()` coerces a `g.` variant on one of them to `m.`."
471/// This surface emitted `NC_012920.1:g.6C>T` for the rCRS — a rule-1 conformance
472/// defect on the most-used mitochondrial accession, in a module that claims rule
473/// 1 as one of the two it delivers. `MtVariant` carries a `GenomeInterval`, so
474/// the template is the only thing that has to change; `build_merged` already
475/// dispatches on `HgvsVariant::Mt`.
476fn reference_template(accession: &str) -> Result<HgvsVariant, FerroError> {
477    // A `g.` description on a transcript or protein accession is not a
478    // description the spec admits — `checklist.md:20` is explicit that an `NM_`
479    // needs a genomic reference to carry genomic coordinates at all — and the
480    // parser will happily build one, because the prefix and the coordinate axis
481    // are independent to it.
482    //
483    // Found by running the cis confluence corpus through this function: its
484    // `c.`-axis classes are drawn against `NM_TEST.1`, and every one of them
485    // came back as `NM_TEST.1:g.<n>del`. That is a well-formed string denoting
486    // nothing, which is the worst shape an output can take.
487    //
488    // Classified through `Accession::inferred_variant_type`, the codebase's own
489    // accession-class table, rather than a prefix list local to this function.
490    // A local list was the first attempt and it leaked exactly the shapes a
491    // second table always leaks: it knew `ENST` but not `ENSMUST`/`ENSRNOT`
492    // (Ensembl feature letters are species-independent, #1057), and knew
493    // nothing of LRG's `t<M>`/`p<M>` discriminator or of UniProt — so
494    // `ENSMUST00000123.1:g.12_13del` and `LRG_1p1:g.12_13del` were both emitted.
495    //
496    // Parse first, then ask the parsed accession: `parse_hgvs` is needed anyway
497    // for the template, and `HgvsVariant::accession` hands the classified
498    // `Accession` straight back.
499    // Parsed on its own, not read back off a `g.=` template, so the
500    // classification below is reachable for the accessions the parser refuses a
501    // `g.` axis on. `parse_accession` is nom-shaped, so the leftover must be
502    // checked: a partial parse would classify a prefix the caller did not write.
503    // Checked before the parse, not after: `parse_accession` does not consume a
504    // bare `P12345` cleanly, so a check placed downstream of it never ran and
505    // the accession fell out with the generic "not a usable genomic accession" —
506    // technically a refusal, but one that reads as "ferro does not know this
507    // accession" rather than "this is a protein".
508    if is_uniprot_shaped(accession) {
509        return Err(non_genomic_refusal(accession));
510    }
511    // Parsed with the `:g.=` suffix attached, which is how the parser is
512    // designed to see an accession: `parse_simple_accession` locates the HGVS
513    // separator to know where the accession ends, so a **bare** SAM refname
514    // (`chr1`, `scaffold_123`, `my-contig.v2`) is not fully consumed on its own.
515    // Classifying off the bare string refused every one of those, which the
516    // shipped surface accepts — `inferred_variant_type` returns `None` for an
517    // unclassifiable accession and `None` is not a refusal.
518    //
519    // The suffix is required back verbatim rather than merely allowed, so a
520    // partial parse cannot classify a prefix the caller did not write.
521    let probe = format!("{accession}:g.=");
522    let parsed = match crate::hgvs::parser::accession::parse_accession(&probe) {
523        Ok((":g.=", parsed)) => parsed,
524        _ => {
525            return Err(FerroError::InvalidCoordinates {
526                msg: format!("'{accession}' is not a usable genomic accession"),
527            })
528        }
529    };
530
531    // `inferred_variant_type` covers the classes it knows; the model-prediction
532    // (`XM`/`XR`/`XP`) and RefSeq-protein (`YP`/`AP`) prefixes are not in that
533    // table, so they are named here explicitly rather than assumed absent. That
534    // asymmetry is the argument for widening the shared table one day, not for
535    // keeping a second one.
536    const NON_GENOMIC_PREFIXES: [&str; 5] = ["XM", "XR", "XP", "YP", "AP"];
537    let prefix = parsed.prefix.to_string();
538    let inferred = parsed.inferred_variant_type();
539    // `is_uniprot` is checked explicitly because `inferred_variant_type`'s
540    // UniProt arm keys on a **one-character** prefix, and this parser hands back
541    // `P12345` as a single prefix with no number — so the table's `p` verdict
542    // never fired and `P12345:g.6C>G` was emitted. `is_uniprot` reads the same
543    // shape off the accession as a whole.
544    //
545    // Deliberately NOT extended to `ENSG`: `inferred_variant_type` classifies an
546    // Ensembl gene as `g`, and that is the codebase's own table. Overriding it
547    // here would put a second, disagreeing classifier in exactly the place the
548    // comment above argues against one.
549    if inferred.is_some_and(|axis| axis != "g")
550        || NON_GENOMIC_PREFIXES.contains(&&*prefix)
551        || parsed.is_uniprot()
552    {
553        return Err(non_genomic_refusal(accession));
554    }
555
556    // `m.` on the two rCRS accessions; see this function's doc.
557    let axis = if parsed.is_mitochondrial() { "m" } else { "g" };
558    crate::parse_hgvs(&format!("{accession}:{axis}.=")).map_err(|_| {
559        FerroError::InvalidCoordinates {
560            msg: format!("'{accession}' is not a usable genomic accession"),
561        }
562    })
563}
564
565/// The refusal for an accession that names a transcript or a protein.
566fn non_genomic_refusal(accession: &str) -> FerroError {
567    FerroError::InvalidCoordinates {
568        msg: format!(
569            "'{accession}' names a transcript or protein rather than a genomic sequence; \
570             from_sequences derives genomic (g.) and mitochondrial (m.) descriptions only, and a \
571             g. description on such a reference is not one the recommendations admit \
572             (checklist.md:20). Project the result onto a transcript axis instead."
573        ),
574    }
575}
576
577/// Whether `accession` has UniProt's shape — one upper-case letter followed by
578/// five alphanumerics — read off the raw string.
579///
580/// `Accession::is_uniprot` asks the same question of a *split* accession
581/// (`prefix` one character, `number` five). This parser does not always split a
582/// UniProt accession that way, so the predicate answered `false` for `P12345`
583/// and the protein sailed through the genomic gate. Kept next to the gate rather
584/// than pushed into `Accession`, because widening that predicate is a change to
585/// a type every axis reads and is not this change's to make.
586fn is_uniprot_shaped(accession: &str) -> bool {
587    let bytes = accession.as_bytes();
588    bytes.len() == 6
589        && bytes[0].is_ascii_uppercase()
590        && bytes[1..].iter().all(u8::is_ascii_alphanumeric)
591}
592
593/// Re-type any member whose payload is the reverse complement of the bases it
594/// replaces as an `inv`.
595///
596/// # Why this pass exists at all
597///
598/// [`super::merge::anchor_for_piece`] deliberately does **not** type inversions.
599/// Its doc says so and gives the reason: on the shipped path the `inv` "is left
600/// to `crate::normalize::rules`'s single-span typing, which can still see it in
601/// the rendered member" — that is, `canonicalize_from_sequence`'s output is an
602/// *intermediate*, handed back to `normalize_core` for exactly this kind of
603/// typing. This module does not run `normalize_core`, so without this pass it
604/// ships the intermediate.
605///
606/// Measured before the fix: `AACC` -> `GGTT` came back as
607/// `g.11_14delinsGGTT` where `normalize` gives `g.11_14inv`, and both
608/// Mutalyzer 3 and VariantValidator rewrite the `delins` spelling to `inv` on
609/// `NC_000001.11:g.1000000_1000003delinsCACC`.
610///
611/// **Whether the un-typed form is non-conformant (rule 1) or merely
612/// non-preferred (rule 2) is NOT settled here, and the fix does not depend on
613/// it.** `DNA/delins.md:5` defines a delins as a replacement "**and which is
614/// not** a substitution or inversion", which reads as a definitional exclusion;
615/// but `general.md:56`'s preference list does not rank `delins` at all, and no
616/// `class="invalid"` marks this shape. Both tools *rewrote* rather than
617/// rejected, which is what one does with a valid-but-non-preferred form. The
618/// question belongs in the ruling ledger; emitting `inv` is right either way.
619///
620/// # Scope, stated so the gap is not mistaken for completeness
621///
622/// This closes the **inversion** half of the typing gap. A sweep of 6 000
623/// random shapes against `normalize` also found **repeat notation**
624/// (`g.27_28insAAA` -> `g.27A[4]`) still un-typed here, because a tandem tract
625/// can extend past the caller's window and so is not always decidable from it.
626/// Everything else the sweep surfaced is reference-anchored member
627/// re-derivation — rules 2 and 3, which this module never claimed.
628fn retype_inversions(members: &mut [HgvsVariant], reference: &[u8], w_lo: i64) {
629    for member in members.iter_mut() {
630        // `Mt` alongside `Genome`: a mitochondrial member carries the same
631        // `GenomeInterval`/`NaEdit` shape, and leaving it out would have made
632        // `inv` typing silently axis-dependent the moment `m.` started being
633        // emitted.
634        let loc_edit = match member {
635            HgvsVariant::Genome(variant) => &mut variant.loc_edit,
636            HgvsVariant::Mt(variant) => &mut variant.loc_edit,
637            _ => continue,
638        };
639        // `Mu::Certain` only: an edit the input marked uncertain must not be
640        // sharpened into a definite `inv`.
641        let Mu::Certain(NaEdit::Delins { sequence, .. }) = &loc_edit.edit else {
642            continue;
643        };
644        let InsertedSequence::Literal(payload) = sequence else {
645            continue;
646        };
647        let payload: Vec<u8> = payload.to_string().into_bytes();
648        // `inversion.md:14` — an inversion covers more than one nucleotide; a
649        // one-base complement is a substitution, which `build_naedit` already
650        // renders from the single reference base.
651        if payload.len() < 2 {
652            continue;
653        }
654        // Only a plain, certain two-endpoint span is re-typed. An uncertain or
655        // ranged boundary states less than an `inv` would claim, so it is left
656        // exactly as it was rather than sharpened.
657        let (
658            UncertainBoundary::Single(Mu::Certain(start)),
659            UncertainBoundary::Single(Mu::Certain(end)),
660        ) = (&loc_edit.location.start, &loc_edit.location.end)
661        else {
662            continue;
663        };
664        if start.special.is_some() || end.special.is_some() {
665            continue;
666        }
667        // Window offsets. Both endpoints are 1-based inclusive on the same axis
668        // as `w_lo`, so the span is `[start - w_lo, end - w_lo]`.
669        let (Ok(lo), Ok(hi)) = (
670            usize::try_from(start.base as i64 - w_lo),
671            usize::try_from(end.base as i64 - w_lo),
672        ) else {
673            continue;
674        };
675        if hi < lo || hi >= reference.len() {
676            continue;
677        }
678        // Delegated to `rules::canonicalize_delins`, the same classifier the
679        // shipped path uses, rather than a local reverse-complement test. A
680        // local test was the first attempt and it missed 85 of 6 000 sampled
681        // shapes, because the classifier trims shared affixes *before* testing
682        // and can therefore shorten the span — `g.12_16delinsCTTTT` is an
683        // inversion of a sub-range, which a whole-span comparison cannot see.
684        let outcome = crate::normalize::rules::canonicalize_delins(reference, lo, hi + 1, &payload);
685        let crate::normalize::rules::DelinsCanonical::Inversion {
686            start: inv_lo,
687            end: inv_hi,
688        } = outcome
689        else {
690            continue;
691        };
692        // `canonicalize_delins` reports 0-based half-open offsets into the
693        // window; the description carries 1-based inclusive positions on the
694        // caller's axis.
695        let (Ok(new_start), Ok(new_end)) = (
696            u64::try_from(inv_lo as i64 + w_lo),
697            u64::try_from(inv_hi as i64 + w_lo - 1),
698        ) else {
699            continue;
700        };
701        loc_edit.location.start =
702            UncertainBoundary::Single(Mu::Certain(crate::hgvs::location::GenomePos {
703                base: new_start,
704                special: None,
705                offset: None,
706            }));
707        loc_edit.location.end =
708            UncertainBoundary::Single(Mu::Certain(crate::hgvs::location::GenomePos {
709                base: new_end,
710                special: None,
711                offset: None,
712            }));
713        loc_edit.edit = Mu::Certain(NaEdit::Inversion {
714            sequence: None,
715            length: None,
716        });
717    }
718}
719
720/// Split each equal-length `delins` member into its canonical decomposition —
721/// independent substitutions and any interior `inv` — the way
722/// `Normalizer::normalize`'s per-member `apply_canonical_split` does, but from
723/// the window reference bytes this surface already holds rather than a provider
724/// fetch.
725///
726/// The place chain in `derive_block_members` merges a substitution flanking an
727/// inversion into one spanning `delins` (e.g. `ATGCG -> CTCGC`), exactly as
728/// `canonicalize_from_sequence`'s own inner chain does — measured: both reach
729/// `g.7_11delinsCTCGC` before any member-level pass. `normalize` then re-splits
730/// it to `g.[7A>C;9_11inv]` under `general.md:56` priority (a maximal
731/// reverse-complement run is an `inv`; two mismatches separated by an unchanged
732/// base are individual substitutions), via `apply_canonical_split` in its
733/// per-member pipeline. Without this port the two surfaces disagree on every
734/// such block — the largest remaining ThreePrime divergence class (#2161).
735///
736/// `retype_inversions` above handles the *whole-span* reverse complement;
737/// `decompose_delins` deliberately declines that shape
738/// (`decompose_full_span_inv_returns_none`) and handles the *partial* one, so the
739/// two passes are complementary and run in that order.
740///
741/// Scope falls out of `decompose_delins`: it requires `alt.len() == ref.len()`
742/// and returns `None` otherwise, so a net-deletion or net-insertion `delins` —
743/// which `delins-merge-vs-individual-gap-two-or-more` keeps whole — is left
744/// untouched. `Genome`/`Mt` only (the axes this surface emits); codon-frame
745/// merging is off, there being no reading frame on a genomic axis.
746fn split_canonical_delins(
747    members: Vec<HgvsVariant>,
748    reference: &[u8],
749    w_lo: i64,
750) -> Vec<HgvsVariant> {
751    let mut out: Vec<HgvsVariant> = Vec::with_capacity(members.len());
752    for member in members {
753        match decompose_member_delins(&member, reference, w_lo) {
754            Some(split) => out.extend(split),
755            None => out.push(member),
756        }
757    }
758    out
759}
760
761/// The per-member half of [`split_canonical_delins`]: the split members when
762/// `member` is an equal-length `Genome`/`Mt` `delins` that decomposes, else
763/// `None` (the caller keeps the member unchanged).
764fn decompose_member_delins(
765    member: &HgvsVariant,
766    reference: &[u8],
767    w_lo: i64,
768) -> Option<Vec<HgvsVariant>> {
769    let loc_edit = match member {
770        HgvsVariant::Genome(v) => &v.loc_edit,
771        HgvsVariant::Mt(v) => &v.loc_edit,
772        _ => return None,
773    };
774    let Mu::Certain(NaEdit::Delins { sequence, .. }) = &loc_edit.edit else {
775        return None;
776    };
777    let InsertedSequence::Literal(payload) = sequence else {
778        return None;
779    };
780    let payload: Vec<u8> = payload.to_string().into_bytes();
781    // Only a plain, certain two-endpoint span, mirroring `retype_inversions`.
782    let (
783        UncertainBoundary::Single(Mu::Certain(start)),
784        UncertainBoundary::Single(Mu::Certain(end)),
785    ) = (&loc_edit.location.start, &loc_edit.location.end)
786    else {
787        return None;
788    };
789    if start.special.is_some() || end.special.is_some() {
790        return None;
791    }
792    // Window offsets: both endpoints are 1-based inclusive on `w_lo`'s axis.
793    let (Ok(lo), Ok(hi)) = (
794        usize::try_from(start.base as i64 - w_lo),
795        usize::try_from(end.base as i64 - w_lo),
796    ) else {
797        return None;
798    };
799    if hi < lo || hi >= reference.len() {
800        return None;
801    }
802    let span = &reference[lo..=hi];
803    // `decompose_delins` requires `payload.len() == span.len()`; a net del/ins
804    // returns `None` here, which is the scope guard.
805    let subedits = crate::normalize::rules::decompose_delins(span, 0, span.len(), &payload)?;
806    // Positions from `decompose_delins` are 0-indexed offsets into `span`, whose
807    // offset 0 is the member's own start base — so `hgvs_start` is `start.base`.
808    Some(crate::normalize::build_split_variants(
809        member, subedits, start.base, false,
810    ))
811}
812
813/// Re-apply the derived description to the supplied reference and require it to
814/// reproduce `alternate` byte for byte.
815///
816/// A **runtime** check, not a `debug_assert`. Emitting a description that
817/// denotes different bases is the worst failure available here, and a debug
818/// assertion is compiled out of exactly the builds that process real data — the
819/// same reasoning `canonicalize_from_sequence` gives for its own round trip.
820///
821/// **It is not one of the four seam oracles, and it is not a substitute for
822/// them.** Those run from `Normalizer::assert_seam_oracles`, at the single exit
823/// of `normalize_core_checked`, which only
824/// [`crate::Normalizer::from_sequences`] with `recommended_form = true` reaches — the
825/// free functions and [`crate::SequencePair::derive`] hold no provider and so
826/// cannot reach it at all. In particular this shares
827/// [`super::merge::apply_edits_to_window`] with the derivation it is checking,
828/// where `FERRO_ASSERT_SEQUENCE`'s
829/// [`crate::spdi::compare_denoted_sequences`] deliberately routes through
830/// `hgvs_to_spdi` so that it does not.
831///
832/// The gap is stated rather than papered over because it cannot be closed here:
833/// the three reference-free oracles have nothing to compare against (there is no
834/// *input* description — the input is bases), and the denoted-sequence oracle
835/// needs a reference this path does not read. The corpus and multi-member axes
836/// are where the independent comparison is made instead, and both make it
837/// through `hgvs_to_spdi`.
838fn verify_round_trip(
839    variant: &HgvsVariant,
840    w_lo: i64,
841    reference: &[u8],
842    alternate: &[u8],
843) -> Result<(), FerroError> {
844    // Borrowed, not cloned. This runs on **every** derivation — it is a runtime
845    // check by design, not a `debug_assert` — and `denoted_bases` takes a
846    // slice, so cloning every member here bought nothing but a `Vec` and one
847    // deep copy per member on the hot path.
848    let members: &[HgvsVariant] = match variant {
849        HgvsVariant::Allele(allele) => &allele.variants,
850        // The identity description denotes the reference; there is nothing to
851        // apply, and `collect_canonical_edits` has no edit to collect.
852        _ if reference == alternate => return Ok(()),
853        other => std::slice::from_ref(other),
854    };
855    let rebuilt =
856        denoted_bases(members, reference, w_lo).ok_or_else(|| FerroError::ConversionError {
857            msg: format!("derived description {variant} could not be re-applied to its window"),
858        })?;
859    if rebuilt != alternate {
860        return Err(FerroError::ConversionError {
861            msg: format!(
862                "derived description {variant} denotes different bases: expected {}, got {}",
863                String::from_utf8_lossy(alternate),
864                String::from_utf8_lossy(&rebuilt)
865            ),
866        });
867    }
868    Ok(())
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    /// The block from `fulcrumgenomics/ferro-hgvs#1420`'s comment `5253702249`:
876    /// one read, four aligner spellings, one variant. `from_sequences` sees only
877    /// the bases, so there is no spelling left for an aligner to perturb.
878    #[test]
879    fn the_1420_read_block_derives_its_alignment_form() {
880        let derived = from_sequences(
881            "NC_TEST.1",
882            130,
883            "GGCGAC",
884            "TACCGAGCTT",
885            &FromSequencesOptions::default(),
886        )
887        .expect("derives");
888        assert_eq!(
889            derived.to_string(),
890            "NC_TEST.1:g.[130_131delinsTAC;134_135insG;135_136insTT]"
891        );
892    }
893
894    /// Two 1 bp deletions separated by two unchanged bases. `partition_block`
895    /// cannot cut this — it searches single-gap alignments only — which is why
896    /// the shipped normalizer leaves the equivalent spanning `delins` alone.
897    #[test]
898    fn two_deletions_two_bases_apart_are_two_members() {
899        let derived = from_sequences(
900            "NC_TEST.1",
901            5,
902            "AGCG",
903            "GC",
904            &FromSequencesOptions::default(),
905        )
906        .expect("derives");
907        assert_eq!(derived.to_string(), "NC_TEST.1:g.[5del;8del]");
908    }
909
910    /// An unchanged pair is not an error; it denotes the reference.
911    #[test]
912    fn an_unchanged_pair_is_the_identity_description() {
913        let derived = from_sequences(
914            "NC_TEST.1",
915            5,
916            "ACGT",
917            "ACGT",
918            &FromSequencesOptions::default(),
919        )
920        .expect("derives");
921        assert_eq!(derived.to_string(), "NC_TEST.1:g.=");
922    }
923
924    #[test]
925    fn a_zero_position_is_refused() {
926        let err = from_sequences("NC_TEST.1", 0, "A", "G", &FromSequencesOptions::default())
927            .expect_err("refuses");
928        assert!(err.to_string().contains("1-based"), "{err}");
929    }
930
931    #[test]
932    fn an_empty_reference_is_refused() {
933        let err = from_sequences("NC_TEST.1", 5, "", "G", &FromSequencesOptions::default())
934            .expect_err("refuses");
935        assert!(err.to_string().contains("no interval"), "{err}");
936    }
937
938    /// **The rCRS gets `m.`, not `g.`** — `Accession::is_mitochondrial`'s own
939    /// doc says HGVS requires the `m.` coordinate system for these accessions,
940    /// so emitting `g.` here was a rule-1 defect on the most-used mitochondrial
941    /// reference. `NC_001807` is the older rCRS draft and is in the same table.
942    #[test]
943    fn a_mitochondrial_accession_derives_on_the_m_axis() {
944        for accession in ["NC_012920.1", "NC_001807.4"] {
945            let derived = from_sequences(accession, 6, "C", "T", &FromSequencesOptions::default())
946                .expect("derives");
947            assert_eq!(derived.to_string(), format!("{accession}:m.6C>T"));
948        }
949        // The neighbouring non-mitochondrial `NC_` is untouched.
950        let genomic = from_sequences(
951            "NC_000001.11",
952            6,
953            "C",
954            "T",
955            &FromSequencesOptions::default(),
956        )
957        .expect("derives");
958        assert_eq!(genomic.to_string(), "NC_000001.11:g.6C>T");
959    }
960
961    /// A multi-base mitochondrial inversion still types as `inv`, which pins
962    /// that `retype_inversions` sees an `Mt` member and not only a `Genome` one.
963    #[test]
964    fn a_mitochondrial_inversion_is_typed() {
965        let derived = from_sequences(
966            "NC_012920.1",
967            11,
968            "AACC",
969            "GGTT",
970            &FromSequencesOptions::default(),
971        )
972        .expect("derives");
973        assert_eq!(derived.to_string(), "NC_012920.1:m.11_14inv");
974    }
975
976    /// **A soft-masked window derives, and its round trip passes.**
977    ///
978    /// `validate` always admitted lower case (`Base::from_char` folds), but
979    /// `apply_edits_to_window` copies the reference verbatim while splicing an
980    /// upper-case payload, so `verify_round_trip`'s byte comparison rejected a
981    /// derivation that was correct: `acgt` -> `atgt` refused with "expected
982    /// atgt, got aTgt". `del` and `dup` passed, since neither carries a payload
983    /// — which is why this pins a substitution, a delins and an inversion.
984    #[test]
985    fn a_soft_masked_window_derives() {
986        for (reference, alternate, expected) in [
987            ("acgt", "atgt", "NC_TEST.1:g.6C>T"),
988            ("acgt", "act", "NC_TEST.1:g.7del"),
989            ("acgt", "acggt", "NC_TEST.1:g.7dup"),
990            ("acgtacgt", "acgttgca", "NC_TEST.1:g.9_12delinsTGCA"),
991            ("ttaacct", "ttggttt", "NC_TEST.1:g.7_10inv"),
992        ] {
993            let derived = from_sequences(
994                "NC_TEST.1",
995                5,
996                reference,
997                alternate,
998                &FromSequencesOptions::default(),
999            )
1000            .unwrap_or_else(|e| panic!("{reference}/{alternate} must derive: {e}"));
1001            assert_eq!(derived.to_string(), expected);
1002        }
1003    }
1004
1005    /// Case is folded, so a masked window and its upper-case twin are the same
1006    /// input — including the mixed-case pair `to_sequences` used to manufacture
1007    /// by upper-casing only its 5' flank.
1008    #[test]
1009    fn case_does_not_change_the_answer() {
1010        let options = FromSequencesOptions::default();
1011        let upper = from_sequences("NC_TEST.1", 5, "ACGTACGT", "ACGTTGCA", &options)
1012            .expect("derives")
1013            .to_string();
1014        for (reference, alternate) in [
1015            ("acgtacgt", "acgttgca"),
1016            ("ACGTacgt", "ACGTtgca"),
1017            ("acgtACGT", "acgtTGCA"),
1018        ] {
1019            let derived = from_sequences("NC_TEST.1", 5, reference, alternate, &options)
1020                .expect("derives")
1021                .to_string();
1022            assert_eq!(derived, upper, "{reference}/{alternate}");
1023        }
1024    }
1025
1026    /// `U` is RNA and this surface's axis is DNA. It used to be admitted
1027    /// asymmetrically — `ACGU` -> `ACGT` emitted `g.8U>T` while `ACGT` ->
1028    /// `AUGT` refused with the round trip's internal message.
1029    #[test]
1030    fn uracil_is_refused_on_both_sides() {
1031        for (reference, alternate) in [("ACGT", "AUGT"), ("ACGU", "ACGT"), ("acgu", "acgt")] {
1032            let err = from_sequences(
1033                "NC_TEST.1",
1034                5,
1035                reference,
1036                alternate,
1037                &FromSequencesOptions::default(),
1038            )
1039            .expect_err("refuses");
1040            assert!(err.to_string().contains("which is RNA"), "{err}");
1041        }
1042    }
1043
1044    /// Every non-genomic class reaches the informative refusal, including the
1045    /// two that used to fall out of the parse arm with "not a usable genomic
1046    /// accession" (`NR_`/`XR_`, which the parser refuses a `g.` axis on) and the
1047    /// UniProt shape, which passed the gate outright and emitted `P12345:g.6C>G`.
1048    #[test]
1049    fn every_non_genomic_class_gets_the_named_refusal() {
1050        for accession in [
1051            "NM_000518.4",
1052            "NR_000001.1",
1053            "XR_000001.1",
1054            "XM_000001.1",
1055            "NP_000509.1",
1056            "ENST00000012345.1",
1057            "ENSMUST00000012345.1",
1058            "LRG_1t1",
1059            "LRG_1p1",
1060            "P12345",
1061        ] {
1062            let err = from_sequences(
1063                accession,
1064                5,
1065                "ACGT",
1066                "AGGT",
1067                &FromSequencesOptions::default(),
1068            )
1069            .expect_err("refuses");
1070            assert!(
1071                err.to_string().contains("names a transcript or protein"),
1072                "{accession}: {err}"
1073            );
1074        }
1075    }
1076
1077    /// **An accession the classifier cannot place is accepted, not refused.**
1078    ///
1079    /// `inferred_variant_type` returns `None` for a SAM refname, a custom contig
1080    /// and an assembly reference, and `None` is not a verdict of "non-genomic" —
1081    /// ferro cannot call `chr1:g.6C>G` wrong. Pinned because classifying off the
1082    /// **bare** accession string broke every one of these at once: the parser
1083    /// finds the end of an accession by locating the HGVS separator, so a bare
1084    /// `chr1` is not fully consumed and fell into the "not a usable genomic
1085    /// accession" arm. The gate now probes with the `:g.=` suffix attached.
1086    #[test]
1087    fn an_unclassifiable_accession_is_still_derived() {
1088        for accession in [
1089            "chr1",
1090            "chrM",
1091            "1",
1092            "scaffold_123",
1093            "my-contig.v2",
1094            "GRCh38(chr1)",
1095            "hg38(chr1)",
1096            "NZ_CP012345.1",
1097            "AC_000001.1",
1098            "NW_000001.1",
1099            "LRG_1",
1100        ] {
1101            let derived = from_sequences(
1102                accession,
1103                5,
1104                "ACGT",
1105                "AGGT",
1106                &FromSequencesOptions::default(),
1107            )
1108            .unwrap_or_else(|e| panic!("{accession} must derive: {e}"));
1109            assert_eq!(derived.to_string(), format!("{accession}:g.6C>G"));
1110        }
1111    }
1112
1113    #[test]
1114    fn an_alignment_only_symbol_is_refused_by_clause() {
1115        let err = from_sequences(
1116            "NC_TEST.1",
1117            5,
1118            "ACGT",
1119            "ACXT",
1120            &FromSequencesOptions::default(),
1121        )
1122        .expect_err("refuses");
1123        assert!(err.to_string().contains("standards.md:39"), "{err}");
1124    }
1125
1126    /// The budget refuses rather than degrading, and the message names the knob
1127    /// and its cost — a refusal that does not say how to proceed is a dead end.
1128    #[test]
1129    fn a_window_over_the_grid_budget_is_refused_with_the_knob_named() {
1130        let options = FromSequencesOptions {
1131            max_grid_cells: 16,
1132            ..Default::default()
1133        };
1134        let err =
1135            from_sequences("NC_TEST.1", 5, "ACGTACGT", "TGCATGCA", &options).expect_err("refuses");
1136        assert!(err.to_string().contains("max_grid_cells"), "{err}");
1137    }
1138
1139    /// A member on the window edge is where a window-local answer and a
1140    /// reference-anchored one can differ, so it is reported rather than absorbed.
1141    #[test]
1142    fn a_member_on_the_window_edge_is_reported() {
1143        let interior = from_sequences_detailed(
1144            "NC_TEST.1",
1145            5,
1146            "TAAAAG",
1147            "TAAAG",
1148            &FromSequencesOptions::default(),
1149        )
1150        .expect("derives");
1151        assert!(
1152            !interior.placement_bounded_by_window(),
1153            "an interior deletion must not flag: {}",
1154            interior.variant
1155        );
1156
1157        let edge = from_sequences_detailed(
1158            "NC_TEST.1",
1159            5,
1160            "AAAA",
1161            "AAA",
1162            &FromSequencesOptions::default(),
1163        )
1164        .expect("derives");
1165        assert!(
1166            edge.placement_bounded_by_window(),
1167            "a deletion flush with the window end must flag: {}",
1168            edge.variant
1169        );
1170    }
1171
1172    /// The combined flag says *that* a member is on an edge; the per-side flags
1173    /// say *which*. A deletion in a homopolymer that fills the window rolls, by
1174    /// default, to the 3' edge — so `bounded_at_end` alone must fire, and a
1175    /// caller widening one side at a time must not be told to widen 5'.
1176    #[test]
1177    fn a_deletion_at_the_three_prime_edge_flags_only_that_side() {
1178        let d = from_sequences_detailed(
1179            "NC_TEST.1",
1180            5,
1181            "AAAA",
1182            "AAA",
1183            &FromSequencesOptions::default(),
1184        )
1185        .expect("derives");
1186        assert!(d.bounded_at_end, "3' edge must flag: {}", d.variant);
1187        assert!(!d.bounded_at_start, "5' edge must not flag: {}", d.variant);
1188        assert!(d.placement_bounded_by_window(), "OR must hold");
1189    }
1190
1191    /// The 5' mirror: under 5'-shuffle the same deletion rolls to the window's
1192    /// 5' edge instead, so `bounded_at_start` alone fires. This is the side a
1193    /// window pinned to base 1 has permanently, and the reason
1194    /// `rederive` reads the two flags apart.
1195    #[test]
1196    fn a_deletion_at_the_five_prime_edge_flags_only_that_side() {
1197        let d = from_sequences_detailed(
1198            "NC_TEST.1",
1199            5,
1200            "AAAA",
1201            "AAA",
1202            &FromSequencesOptions::default().with_direction(ShuffleDirection::FivePrime),
1203        )
1204        .expect("derives");
1205        assert!(d.bounded_at_start, "5' edge must flag: {}", d.variant);
1206        assert!(!d.bounded_at_end, "3' edge must not flag: {}", d.variant);
1207        assert!(d.placement_bounded_by_window(), "OR must hold");
1208    }
1209
1210    /// **Contig-start escape.** An insertion in an ambiguous run reaching the
1211    /// accession's first base has no 5'-most HGVS anchor — the 5'-shuffle rolls
1212    /// it to interbase 0, which is spelled `0_1ins…` and names a position that
1213    /// does not exist. Because `w_lo == 1` here, that interbase *is* the contig
1214    /// start, so widening cannot rescue it. It is instead re-presented at the
1215    /// leftmost nameable interbase (offset 1), the terminal analogue of the
1216    /// 3'-most rule: inserting `AT` before base 1 equals inserting `TA` before
1217    /// base 2, and only the latter can be written. This is the shape the real
1218    /// run's `1A>C`/`1A>T`-anchored multi-substitution alleles hit.
1219    #[test]
1220    fn a_contig_start_insertion_escapes_to_the_leftmost_nameable_interbase() {
1221        let derived = from_sequences(
1222            "NC_TEST.1",
1223            1,
1224            "ATATATG",
1225            "ATATATATG",
1226            &FromSequencesOptions::default().with_direction(ShuffleDirection::FivePrime),
1227        )
1228        .expect("the ambiguous-run insertion escapes to interbase 1 rather than refusing");
1229        assert_eq!(derived.to_string(), "NC_TEST.1:g.1_2insTA");
1230    }
1231
1232    /// **Case (1) with a neighbour**, which every other contig-start fixture
1233    /// here lacks: they all drive a *lone* insertion, so none of them can
1234    /// observe what the escape does to the piece list beside it.
1235    ///
1236    /// `ACG` -> `AACT` is the tightest reachable shape. The partition is one
1237    /// inserted `A` plus a substitution at base 3; the 5'-shuffle rolls the
1238    /// insertion off interbase 1 onto interbase 0 (legal, `alt[0] ==
1239    /// reference[0]`), so case (1) fires and re-presents it at interbase 1 —
1240    /// where it renders as `1dup`, the payload being a copy of base 1 — with
1241    /// `3G>T` still standing beside it.
1242    ///
1243    /// What this pins is that the escape leaves the piece list in the shape
1244    /// `coalesce_adjacent_pieces` and `shrink_pieces_to_differences` had put it
1245    /// in. `verify_round_trip` cannot: it re-applies the members and compares
1246    /// *bases*, and an un-coalesced pair re-applies to `AACT` exactly as a
1247    /// coalesced one does. Shape is not a question it asks.
1248    #[test]
1249    fn a_contig_start_escape_keeps_its_neighbour_separate() {
1250        let derived = from_sequences(
1251            "NC_TEST.1",
1252            1,
1253            "ACG",
1254            "AACT",
1255            &FromSequencesOptions::default().with_direction(ShuffleDirection::FivePrime),
1256        )
1257        .expect("the run insertion escapes to interbase 1 rather than refusing");
1258        assert_eq!(derived.to_string(), "NC_TEST.1:g.[1dup;3G>T]");
1259    }
1260
1261    /// A pure insertion before base 1 with **no piece to fold into** is a
1262    /// genuine insertion at a non-existent anchor and still refuses. Here the
1263    /// whole block is a single inserted `G` (`CATATG` -> `GCATATG` share the
1264    /// suffix `CATATG`), so there is no following delins to absorb it, and the
1265    /// leading base is not itself rewritten — the span-form escape does not
1266    /// apply. The base-1-rewriting span form is covered in the
1267    /// `rederive` integration suite, which drives this same path.
1268    #[test]
1269    fn a_lone_contig_start_insertion_still_refuses() {
1270        let err = from_sequences(
1271            "NC_TEST.1",
1272            1,
1273            "CATATG",
1274            "GCATATG",
1275            &FromSequencesOptions::default().with_direction(ShuffleDirection::FivePrime),
1276        )
1277        .expect_err("an insertion genuinely before base 1 has no HGVS anchor");
1278        assert!(
1279            err.to_string().contains("5' of the window's first base"),
1280            "{err}"
1281        );
1282    }
1283
1284    /// A change spanning the whole window rests on both edges at once, so both
1285    /// per-side flags fire. Neither side can be dismissed as settled, which is
1286    /// what makes a two-sided widen the right response.
1287    #[test]
1288    fn a_change_filling_the_window_flags_both_sides() {
1289        let d = from_sequences_detailed(
1290            "NC_TEST.1",
1291            5,
1292            "ACGT",
1293            "TGCA",
1294            &FromSequencesOptions::default(),
1295        )
1296        .expect("derives");
1297        assert!(d.bounded_at_start, "5' edge must flag: {}", d.variant);
1298        assert!(d.bounded_at_end, "3' edge must flag: {}", d.variant);
1299    }
1300
1301    /// An interior change touches neither edge, so both per-side flags — and
1302    /// therefore the combined flag — are clear.
1303    #[test]
1304    fn an_interior_change_flags_neither_side() {
1305        let d = from_sequences_detailed(
1306            "NC_TEST.1",
1307            5,
1308            "TAAAAG",
1309            "TAAAG",
1310            &FromSequencesOptions::default(),
1311        )
1312        .expect("derives");
1313        assert!(!d.bounded_at_start, "5' edge must not flag: {}", d.variant);
1314        assert!(!d.bounded_at_end, "3' edge must not flag: {}", d.variant);
1315        assert!(!d.placement_bounded_by_window(), "OR must be clear");
1316    }
1317
1318    /// The same four arguments give the same string. Asserted rather than
1319    /// assumed, because "deterministic" is the one property this whole surface
1320    /// is sold on.
1321    #[test]
1322    fn the_same_arguments_give_the_same_description() {
1323        let once = from_sequences(
1324            "NC_TEST.1",
1325            130,
1326            "GGCGAC",
1327            "TACCGAGCTT",
1328            &FromSequencesOptions::default(),
1329        )
1330        .expect("derives");
1331        let twice = from_sequences(
1332            "NC_TEST.1",
1333            130,
1334            "GGCGAC",
1335            "TACCGAGCTT",
1336            &FromSequencesOptions::default(),
1337        )
1338        .expect("derives");
1339        assert_eq!(once.to_string(), twice.to_string());
1340    }
1341
1342    /// Padding the window with matching flank must not move the answer, until
1343    /// the edge stops binding. This is the property that makes a caller's choice
1344    /// of window size safe when the change is interior.
1345    #[test]
1346    fn matching_flank_does_not_move_an_interior_answer() {
1347        let tight = from_sequences(
1348            "NC_TEST.1",
1349            10,
1350            "TAGCGA",
1351            "TGCA",
1352            &FromSequencesOptions::default(),
1353        )
1354        .expect("derives");
1355        let padded = from_sequences(
1356            "NC_TEST.1",
1357            8,
1358            "CCTAGCGAGG",
1359            "CCTGCAGG",
1360            &FromSequencesOptions::default(),
1361        )
1362        .expect("derives");
1363        assert_eq!(tight.to_string(), padded.to_string());
1364    }
1365}