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}