Skip to main content

qsm_core/pipeline/
rules.rs

1//! Pipeline rules: what each method requires, recommends and discourages, with sources.
2//!
3//! Many methods only work as intended with certain other methods, settings or values (the
4//! pipeline their authors ran them in), and some combinations are known to fail. This module
5//! publishes that knowledge as data, so a host can check a configuration and offer the change
6//! instead of letting a user silently run something a method was never meant for.
7//!
8//! A [`Rule`] reads:
9//!
10//! > **when** every [`Condition`] in [`Rule::when`] holds, **then** [`Rule::setting`] should pass
11//! > [`Rule::expect`] (one of a set of values, none of a set, or a range), with
12//! > [`Rule::default`] as the value to apply, at a [`Strength`], for a [`Rule::reason`], citing a
13//! > [`Rule::source`].
14//!
15//! [`evaluate`] checks a configuration ([`RuleInput`]) against every rule in [`RULES`], given what
16//! is known about the scan ([`ScanFacts`]), and returns a [`Finding`] for each rule whose
17//! conditions hold and whose setting does not pass. [`RULES`] is the catalogue; both it and the
18//! findings serialise to JSON (`serde::Serialize`) for hosts that are not written in Rust.
19//!
20//! # Decisions
21//!
22//! - **No source, no rule.** A rule says "the method's reference pipeline does this" (the authors'
23//!   code, file and line, or their paper) or "QSM.rs measured this" (and the measurement is
24//!   recorded in that method's QSM.rs docs). It never encodes a preference. Where the reference is
25//!   ambiguous, there is no rule; the open question is recorded in the method's docs instead.
26//! - **Scoped to the triggering choice.** The first condition in [`Rule::when`] is the choice
27//!   that triggers the rule (usually a method being selected), and the rule is about what that
28//!   choice needs. "MEDI recommends PDF" fires only when MEDI is chosen, never as a general
29//!   statement about background removal. This keeps two rules from disagreeing about one setting
30//!   except where two chosen methods genuinely want different things, and then the host sees both
31//!   findings with their triggers.
32//! - **Two kinds of rule** ([`Rule::kind`], a [`Kind`]; decided 2026-11-04). A host must be able
33//!   to tell "this is what the method's authors did" from "this is better, for this reason",
34//!   because only the second is advice:
35//!   - [`Kind::Reference`], **the original authors' setting**: their code, paper or README does
36//!     or instructs it, and nobody has shown that it is needed or better. Following it reproduces
37//!     their pipeline; departing from it is a legitimate choice. E.g. QSMART's `bet2 -f 0.2` in
38//!     `brainmask.m`, MEDI's region-growing unwrapper after its complex fit, a network trained on
39//!     V-SHARP fields, or a toolbox default such as `Chisep_script.m`'s Tukey window, even where
40//!     the code's comment calls it a "Recommendation".
41//!   - [`Kind::Reasoned`], **a reasoned recommendation**: there is a stated reason why the
42//!     setting is needed or better (the method cannot run, a combination is known to fail, the
43//!     authors explain why, or QSM.rs measured it), and the reason is in [`Rule::reason`]. E.g.
44//!     HEIDI's LSQR seed (the cone fill keeps everything outside the cone), PDF's `n_cg` (measured).
45//!
46//!   The kind is fixed by the [`Strength`]: [`Strength::Reference`] is the reference kind, every
47//!   other strength is reasoned. It is one field rather than two so that the two cannot
48//!   disagree. The JSON form carries both (`"kind"` and `"strength"`).
49//! - **Strength**, and the basis it rests on ([`Rule::basis`], a [`Basis`]):
50//!   - [`Strength::Required`]: the method cannot run correctly otherwise (e.g. it does not fit
51//!     the heap). Usually measured.
52//!   - [`Strength::Recommended`]: the authors say why it is needed or better (their paper, README
53//!     or a comment in their code gives the reason; [`Basis::Stated`]), or QSM.rs measured a
54//!     benefit and recorded it in the method's docs ([`Basis::Measured`]).
55//!   - [`Strength::Discouraged`]: a combination known to fail, stated by the authors or measured
56//!     (or a setting QSM.rs ignores in that combination, [`Basis::Implementation`]).
57//!   - [`Strength::Reference`]: the original authors' setting ([`Kind::Reference`]): their code
58//!     or paper simply does it ([`Basis::ReferenceChoice`]), or their README or a code comment
59//!     instructs it without saying why ([`Basis::Stated`]).
60//!
61//!   Precedence is `Required > Recommended > Discouraged > Reference` (`Strength: Ord`). A
62//!   reason outranks a bare reference choice; a recommendation outranks a discouragement because
63//!   a recommendation names the value to use, while a discouragement only names one to avoid.
64//!   This module reports every finding and leaves resolving them to the host.
65//! - **Intended front-end handling** (QSMxT/QSMxT#419, astewartau/qsmbly#194; 2026-11-04):
66//!   - `Required`: keeps the host's "forces" behaviour; the run does not proceed as configured.
67//!   - `Recommended` and `Discouraged` (reasoned): offered as a change, labelled as a
68//!     recommendation with its reason, with the recommended value **preselected**; a host's
69//!     "accept recommendations" applies them. A badge on the setting afterwards.
70//!   - `Reference`: shown (e.g. when its method is chosen), labelled as the original authors'
71//!     setting, but **never preselected** and never applied by "accept recommendations"; a host
72//!     applies it only when the user picks it or asks for the authors' settings explicitly.
73//!     Recorded in the run record, and listed in the methods' Note section in a softer form
74//!     ("differs from the reference pipeline"), separately from departures from recommendations.
75//! - **Rules that restate a default.** Since #293 most methods' own defaults are their reference
76//!   values, so many `Reference` rules (STAR's padding and TE, RESHARP's radius and iterations,
77//!   iLSQR's iterations and padding, QSMART's iLSQR settings, the reference echo times) hold on
78//!   the default config and fire only when a user departs from it. They are kept: they say what
79//!   the departure is from and why, which the methods Note needs. A host that also shows
80//!   "differs from default" should show one or the other for these, not both.
81//! - **Data, not code.** Conditions and expectations are values ([`Condition`], [`Test`],
82//!   [`Value`]) over named settings and scan facts, not closures, so the JSON form says
83//!   everything the Rust evaluation does. The only code is [`current`] (a setting's value in a
84//!   config) and [`ScanFacts::fact`] (a fact's value for a scan).
85//! - **Settings are QSM.rs's own.** A setting is a dotted path into QSM.rs's config types:
86//!   `masking.*`, `field_mapping.*`, `bg_removal.*`, `inversion.*` ([`QsmPipelineConfig`]),
87//!   `separation.*` ([`SeparationConfig`]) and `segmentation.*` (SynthSeg's
88//!   [`SynthSegParams`]). Enum choices are named by their `id()` (e.g.
89//!   [`InversionAlgorithm::id`](super::config::InversionAlgorithm::id)). A host maps these to its own config paths. [`SETTINGS`] lists
90//!   every path a rule may use.
91//! - **Settings not in play have no value.** A setting of a stage that does not run (background
92//!   removal ahead of a total-field inversion; field mapping ahead of a wrapped-phase one), or of
93//!   a method that is not selected anywhere (HD-BET's parameters with no HD-BET step in the mask
94//!   recipe), has no current value, and a rule on it does not fire. A setting with several values
95//!   (one per mask section) triggers a condition if any value does, and passes a rule's
96//!   expectation only if every value does.
97//! - **Scan facts may be unknown.** A host checks a config before it has seen data. A condition
98//!   on a fact that is not known (`None` in [`ScanFacts`]) does not hold, so scan-dependent rules
99//!   stay silent until the scan is known; evaluate again at plan time with the scan's facts.
100//!   [`Rule::needs_scan`] says which rules those are.
101//! - **Values with scan-relative meaning** are stated in the unit the setting uses ([`Unit`]):
102//!   millimetres, voxels, bytes. A rule never converts mm to voxels itself; where that matters it
103//!   conditions on the voxel size.
104//! - **Inclusive ranges.** [`Test::Range`] bounds are inclusive. Byte and voxel counts are
105//!   integers, so "more than the heap" is `min: heap + 1`.
106
107use serde::Serialize;
108
109use super::config::{QsmPipelineConfig, SeparationConfig};
110use crate::bet::{hd_bet_memory_bytes, HdBetInference, HdBetParams};
111use crate::models::Heap;
112use crate::segment::synthseg::{synthseg_network_grid, SynthSegInference, SynthSegParams};
113
114// =========================================================================
115// Types
116// =========================================================================
117
118/// How strongly a rule applies. Ordered `Reference < Discouraged < Recommended < Required`, so
119/// `max` is the finding that takes precedence on a setting (see the module docs, *Strength*).
120#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
121#[serde(rename_all = "snake_case")]
122pub enum Strength {
123    /// The original authors' setting: what the method's reference pipeline does or instructs,
124    /// where nobody has said why it is needed or better ([`Kind::Reference`]). Never preselected.
125    Reference,
126    /// A combination known to fail (measured, or stated by the authors with the reason).
127    Discouraged,
128    /// Better for a stated reason: the authors say why (paper, README or code comment), or
129    /// QSM.rs measured a benefit and recorded it in the method's docs.
130    Recommended,
131    /// The method cannot run correctly otherwise.
132    Required,
133}
134
135impl Strength {
136    /// The kind of suggestion a rule of this strength is: [`Kind::Reference`] for
137    /// [`Strength::Reference`], [`Kind::Reasoned`] for every other.
138    pub const fn kind(self) -> Kind {
139        match self {
140            Strength::Reference => Kind::Reference,
141            Strength::Discouraged | Strength::Recommended | Strength::Required => Kind::Reasoned,
142        }
143    }
144}
145
146/// Whether a rule is the original authors' setting or a reasoned recommendation (see the module
147/// docs, *Two kinds of rule*). Fixed by the rule's [`Strength`] ([`Strength::kind`]).
148#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
149#[serde(rename_all = "snake_case")]
150pub enum Kind {
151    /// The original authors' setting: their code, paper or README does or instructs it, without
152    /// a reason why it is needed or better. A host shows it, labelled as the authors' setting,
153    /// and never preselects or bulk-applies it.
154    Reference,
155    /// Needed or better for the reason in [`Rule::reason`]: a hard requirement, a known failure,
156    /// the authors' stated reason, or a QSM.rs measurement. A host preselects it.
157    Reasoned,
158}
159
160/// What a rule rests on.
161#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, Hash)]
162#[serde(rename_all = "snake_case")]
163pub enum Basis {
164    /// The authors say so: their paper, README or a comment in their code. With
165    /// [`Strength::Reference`], they instruct it without saying why; otherwise they give the
166    /// reason.
167    Stated,
168    /// QSM.rs measured it (memory, accuracy), and the measurement is in the method's docs.
169    Measured,
170    /// The authors' code or paper does it, without saying it is needed or better.
171    ReferenceChoice,
172    /// What QSM.rs's own implementation does (recorded in its docs), e.g. a setting it ignores
173    /// in some combination.
174    Implementation,
175}
176
177/// A setting's (or a scan fact's) value. Serialised untagged: `true`, `3.0`, `"pdf"`,
178/// `[8.0, 8.0, 20.0]`.
179#[derive(Serialize, Clone, Copy, Debug, PartialEq)]
180#[serde(untagged)]
181pub enum Value {
182    Bool(bool),
183    Number(f64),
184    /// An enum choice, by its `id()` (e.g. `"pdf"`, `"medi"`, `"sliding-window"`).
185    Choice(&'static str),
186    /// A fixed-length numeric setting (per-axis padding, kernel radii).
187    Vector(&'static [f64]),
188}
189
190/// The current value of a setting, owned (a config's vectors are not `'static`).
191#[derive(Serialize, Clone, Debug, PartialEq)]
192#[serde(untagged)]
193pub enum Current {
194    Bool(bool),
195    Number(f64),
196    Choice(&'static str),
197    Vector(Vec<f64>),
198}
199
200impl Current {
201    fn number(&self) -> Option<f64> {
202        match self {
203            Current::Number(x) => Some(*x),
204            _ => None,
205        }
206    }
207
208    fn equals(&self, v: &Value) -> bool {
209        match (self, v) {
210            (Current::Bool(a), Value::Bool(b)) => a == b,
211            (Current::Number(a), Value::Number(b)) => a == b,
212            (Current::Choice(a), Value::Choice(b)) => a == b,
213            (Current::Vector(a), Value::Vector(b)) => a.as_slice() == *b,
214            _ => false,
215        }
216    }
217}
218
219/// A test on a value: what a condition requires of a setting or fact, or what a rule expects of
220/// its setting.
221#[derive(Serialize, Clone, Copy, Debug, PartialEq)]
222#[serde(tag = "test", rename_all = "snake_case")]
223pub enum Test {
224    /// Equal to one of these.
225    OneOf { values: &'static [Value] },
226    /// Equal to none of these.
227    NoneOf { values: &'static [Value] },
228    /// A number within `[min, max]` (inclusive; either bound may be open).
229    Range { min: Option<f64>, max: Option<f64> },
230}
231
232impl Test {
233    fn passes(&self, v: &Current) -> bool {
234        match self {
235            Test::OneOf { values } => values.iter().any(|x| v.equals(x)),
236            Test::NoneOf { values } => !values.iter().any(|x| v.equals(x)),
237            Test::Range { min, max } => v.number().is_some_and(|x| {
238                min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m)
239            }),
240        }
241    }
242}
243
244/// A fact about the scan or the host, as opposed to a setting.
245#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
246#[serde(rename_all = "snake_case")]
247pub enum Fact {
248    /// Main field strength, T.
249    FieldStrength,
250    /// Number of echoes.
251    EchoCount,
252    /// Smallest voxel edge, mm.
253    MinVoxelSize,
254    /// Largest voxel edge, mm.
255    MaxVoxelSize,
256    /// Where the pipeline runs: `"native"` or `"wasm"` ([`Target::id`]).
257    Target,
258    /// Peak bytes of the configured HD-BET step on this scan in a `wasm32` heap
259    /// ([`hd_bet_memory_bytes`] with [`Heap::Wasm32`]; the largest over HD-BET steps).
260    HdBetWasmBytes,
261    /// Voxels of the grid SynthSeg's network sees for this scan ([`synthseg_network_grid`]).
262    SynthSegNetworkVoxels,
263    /// The scanner's manufacturer ([`Manufacturer::id`]: `"siemens"`, `"ge"`, `"philips"` or
264    /// `"other"`), from the BIDS sidecar's `Manufacturer` (DICOM (0008,0070)).
265    Manufacturer,
266}
267
268/// A scanner manufacturer, as far as a rule distinguishes them.
269#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
270#[serde(rename_all = "snake_case")]
271pub enum Manufacturer {
272    Siemens,
273    Ge,
274    Philips,
275    /// Any other (Canon, Toshiba, United Imaging, Hitachi, Bruker, ...).
276    Other,
277}
278
279impl Manufacturer {
280    /// The manufacturer a BIDS `Manufacturer` value (DICOM (0008,0070)) names, as dcm2niix and the
281    /// scanners spell it: `"Siemens"`, `"SIEMENS"`, `"Siemens Healthineers"`; `"GE"`,
282    /// `"GE MEDICAL SYSTEMS"`, `"General Electric"`; `"Philips"`, `"Philips Medical Systems"`,
283    /// `"Philips Healthcare"`. Case and surrounding spaces do not matter; anything else is
284    /// [`Manufacturer::Other`].
285    pub fn from_name(name: &str) -> Self {
286        let m = name.trim().to_uppercase();
287        if m.starts_with("SIEMENS") {
288            Manufacturer::Siemens
289        } else if m == "GE" || m.starts_with("GE ") || m.contains("GENERAL ELECTRIC") {
290            Manufacturer::Ge
291        } else if m.starts_with("PHILIPS") {
292            Manufacturer::Philips
293        } else {
294            Manufacturer::Other
295        }
296    }
297
298    pub fn id(self) -> &'static str {
299        match self {
300            Manufacturer::Siemens => "siemens",
301            Manufacturer::Ge => "ge",
302            Manufacturer::Philips => "philips",
303            Manufacturer::Other => "other",
304        }
305    }
306}
307
308impl Fact {
309    /// Whether the fact depends on the scan (and so is unknown before the scan is).
310    pub fn needs_scan(self) -> bool {
311        !matches!(self, Fact::Target)
312    }
313}
314
315/// One condition of a rule.
316#[derive(Serialize, Clone, Copy, Debug, PartialEq)]
317#[serde(tag = "on", rename_all = "snake_case")]
318pub enum Condition {
319    /// A setting in the config passes `is`. With several current values (one per mask section)
320    /// it holds if any of them passes: a method chosen anywhere triggers its rules. A setting
321    /// not in play never holds.
322    Setting { setting: &'static str, is: Test },
323    /// A scan or host fact passes `is` (an unknown fact never holds).
324    Fact { fact: Fact, is: Test },
325}
326
327/// The unit of a rule's setting, for numbers and vectors.
328#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq)]
329#[serde(rename_all = "snake_case")]
330pub enum Unit {
331    /// A choice, a flag or a dimensionless number.
332    None,
333    Mm,
334    Voxels,
335    Seconds,
336    Bytes,
337}
338
339/// A rule: when [`Self::when`] holds, [`Self::setting`] should pass [`Self::expect`].
340///
341/// Serialised with every field and its [`Kind`] (`"kind"`, from the strength).
342#[derive(Clone, Copy, Debug, PartialEq)]
343pub struct Rule {
344    /// Stable identifier, `<method>.<what>` in kebab case.
345    pub id: &'static str,
346    /// Conditions, all of which must hold. The first is the triggering choice the rule is scoped
347    /// to (see the module docs).
348    pub when: &'static [Condition],
349    /// The setting the rule is about (a path in [`SETTINGS`]).
350    pub setting: &'static str,
351    /// What the setting should pass.
352    pub expect: Test,
353    /// The value to apply to resolve the finding (one that passes `expect`).
354    pub default: Value,
355    /// Unit of `default` and of any range in `expect`.
356    pub unit: Unit,
357    pub strength: Strength,
358    /// What the rule rests on: a statement by the authors, a QSM.rs measurement, the reference's
359    /// choice, or QSM.rs's implementation.
360    pub basis: Basis,
361    /// One line: why.
362    pub reason: &'static str,
363    /// The authors' code (file, line) or paper (section), or the QSM.rs docs recording the
364    /// measurement.
365    pub source: &'static str,
366}
367
368impl Rule {
369    /// The triggering choice (the first condition).
370    pub fn trigger(&self) -> &'static Condition {
371        &self.when[0]
372    }
373
374    /// The original authors' setting, or a reasoned recommendation ([`Strength::kind`]).
375    pub fn kind(&self) -> Kind {
376        self.strength.kind()
377    }
378
379    /// Whether the rule can only fire once the scan is known.
380    pub fn needs_scan(&self) -> bool {
381        self.when.iter().any(|c| matches!(c, Condition::Fact { fact, .. } if fact.needs_scan()))
382    }
383}
384
385impl Serialize for Rule {
386    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
387        use serde::ser::SerializeStruct;
388        let mut m = s.serialize_struct("Rule", 11)?;
389        m.serialize_field("id", self.id)?;
390        m.serialize_field("when", self.when)?;
391        m.serialize_field("setting", self.setting)?;
392        m.serialize_field("expect", &self.expect)?;
393        m.serialize_field("default", &self.default)?;
394        m.serialize_field("unit", &self.unit)?;
395        m.serialize_field("kind", &self.kind())?;
396        m.serialize_field("strength", &self.strength)?;
397        m.serialize_field("basis", &self.basis)?;
398        m.serialize_field("reason", self.reason)?;
399        m.serialize_field("source", self.source)?;
400        m.end()
401    }
402}
403
404/// A rule that fires on a configuration: its conditions hold and its setting does not pass.
405#[derive(Serialize, Clone, Debug, PartialEq)]
406pub struct Finding {
407    #[serde(flatten)]
408    pub rule: &'static Rule,
409    /// The setting's current value(s), one per place it is set (e.g. one per mask section).
410    pub current: Vec<Current>,
411}
412
413/// Where the pipeline runs. A WASM host has a 32-bit heap ([`WASM32_HEAP_BYTES`]).
414#[derive(Serialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
415#[serde(rename_all = "snake_case")]
416pub enum Target {
417    #[default]
418    Native,
419    Wasm,
420}
421
422impl Target {
423    /// The target this build runs on.
424    pub fn current() -> Self {
425        if cfg!(target_arch = "wasm32") { Target::Wasm } else { Target::Native }
426    }
427
428    pub fn id(self) -> &'static str {
429        match self {
430            Target::Native => "native",
431            Target::Wasm => "wasm",
432        }
433    }
434}
435
436/// The hard ceiling of a 32-bit WASM linear memory: 4 GiB. A step whose estimated peak exceeds
437/// it cannot run in a browser at all.
438pub const WASM32_HEAP_BYTES: u64 = 1 << 32;
439
440/// What is known about the scan and the host. Every scan fact is optional: a host checks a config
441/// before it has seen data, and a rule conditioned on an unknown fact does not fire.
442#[derive(Clone, Debug, Default, PartialEq)]
443pub struct ScanFacts {
444    pub field_strength: Option<f64>,
445    pub echo_count: Option<usize>,
446    /// Voxel size in mm.
447    pub voxel_size: Option<(f64, f64, f64)>,
448    /// Matrix size in voxels.
449    pub dims: Option<(usize, usize, usize)>,
450    /// The scanner's manufacturer, from the sidecar ([`Manufacturer::from_name`]).
451    pub manufacturer: Option<Manufacturer>,
452    pub target: Target,
453}
454
455impl ScanFacts {
456    /// Every fact of a scan's metadata, on `target`. [`ScanMetadata`](super::config::ScanMetadata)
457    /// does not carry the manufacturer; a host that read it sets [`Self::manufacturer`].
458    pub fn from_metadata(m: &super::config::ScanMetadata, target: Target) -> Self {
459        Self {
460            field_strength: Some(m.field_strength),
461            echo_count: Some(m.echo_times.len()),
462            voxel_size: Some(m.voxel_size),
463            dims: Some(m.dims),
464            manufacturer: None,
465            target,
466        }
467    }
468
469    fn grid(&self) -> Option<crate::Grid> {
470        Some(crate::Grid { dims: self.dims?, voxel_size: self.voxel_size? })
471    }
472
473    /// The fact's value for this scan and config, if known.
474    pub fn fact(&self, fact: Fact, input: &RuleInput) -> Option<Current> {
475        let vs = |f: fn(f64, f64) -> f64| self.voxel_size.map(|(x, y, z)| f(f(x, y), z));
476        match fact {
477            Fact::FieldStrength => self.field_strength.map(Current::Number),
478            Fact::EchoCount => self.echo_count.map(|n| Current::Number(n as f64)),
479            Fact::MinVoxelSize => vs(f64::min).map(Current::Number),
480            Fact::MaxVoxelSize => vs(f64::max).map(Current::Number),
481            Fact::Target => Some(Current::Choice(self.target.id())),
482            Fact::HdBetWasmBytes => {
483                let grid = self.grid()?;
484                hd_bet_steps(input.pipeline)
485                    .map(|p| hd_bet_memory_bytes(&grid, p, Heap::Wasm32))
486                    .max()
487                    .map(|b| Current::Number(b as f64))
488            }
489            Fact::SynthSegNetworkVoxels => {
490                let grid = self.grid()?;
491                let d = synthseg_network_grid(&grid, input.segmentation?);
492                Some(Current::Number(d.iter().map(|&v| v as f64).product()))
493            }
494            Fact::Manufacturer => self.manufacturer.map(|m| Current::Choice(m.id())),
495        }
496    }
497}
498
499/// The configuration a host checks: the QSM pipeline, and the optional stages it runs.
500#[derive(Clone, Copy, Debug)]
501pub struct RuleInput<'a> {
502    pub pipeline: &'a QsmPipelineConfig,
503    /// The χ-separation stage's config, when it runs.
504    pub separation: Option<&'a SeparationConfig>,
505    /// SynthSeg's parameters, when segmentation runs.
506    pub segmentation: Option<&'a SynthSegParams>,
507}
508
509impl<'a> RuleInput<'a> {
510    /// Just the QSM pipeline: no separation, no segmentation.
511    pub fn pipeline(pipeline: &'a QsmPipelineConfig) -> Self {
512        Self { pipeline, separation: None, segmentation: None }
513    }
514}
515
516// =========================================================================
517// Settings
518// =========================================================================
519
520/// Every setting path a rule may name, with what it is. [`current`] reads each of them.
521pub const SETTINGS: &[(&str, &str)] = &[
522    ("masking.generator", "Each mask section's generator (MaskOp::id)"),
523    ("masking.hd_bet.inference", "Each HD-BET step's inference: full-volume, sliding-window (HdBetInference::SLIDING_WINDOW), low-memory (HdBetInference::LOW_MEMORY) or custom-window"),
524    ("field_mapping.unwrapping_algorithm", "Phase unwrapping (UnwrappingAlgorithm::id)"),
525    ("field_mapping.b0_estimation", "Echo combination (B0EstimationMethod::id)"),
526    ("field_mapping.b0_estimation.weighting", "Weighted average's per-echo weight (B0WeightType, by `weight_id`); only with weighted-avg"),
527    ("field_mapping.phase_offset_removal", "MCPC-3D-S phase-offset removal before unwrapping (bool)"),
528    ("field_mapping.phase_offset_sigma_mm", "MCPC-3D-S smoothing sigma per axis, mm; only with phase-offset removal"),
529    ("field_mapping.bipolar_correction", "Bipolar readout correction (bool)"),
530    ("field_mapping.laplacian_solver", "Laplacian unwrapping's solver: dct, fft-12 (LaplacianSolver::FFT_DEFAULT_PAD), fft (other padding) or mirror; only with Laplacian unwrapping"),
531    ("field_mapping.laplacian_unmasked", "Laplacian unwrapping on the raw phase over the whole volume, not the phase zeroed outside the mask (bool); only with Laplacian unwrapping"),
532    ("bg_removal.algorithm", "Background field removal (BgRemovalAlgorithm::id)"),
533    ("bg_removal.vsharp.deconvolve", "V-SHARP's TSVD deconvolution (bool); only with V-SHARP"),
534    ("bg_removal.vsharp.max_radius", "V-SHARP's largest kernel radius, mm; only with V-SHARP"),
535    ("bg_removal.vsharp.min_radius", "V-SHARP's smallest kernel radius, mm; only with V-SHARP"),
536    ("bg_removal.vsharp.radius_step", "V-SHARP's step between kernel radii, mm; only with V-SHARP"),
537    ("bg_removal.vsharp.grid", "V-SHARP's working grid: native, isotropic-finest, isotropic-1mm or isotropic (other spacing); only with V-SHARP"),
538    ("bg_removal.ismv.variant", "iSMV's implementation: wen2014 or qsmjl (IsmvVariant::id); only with iSMV"),
539    ("bg_removal.ismv.tol", "iSMV's stopping tolerance (IsmvParams::tol); only with iSMV"),
540    ("bg_removal.ismv.max_iter", "iSMV's iteration cap (IsmvParams::max_iter); no cap reads as +∞ (null in JSON); only with iSMV"),
541    ("bg_removal.resharp.radius", "RESHARP's kernel radius, mm; only with RESHARP"),
542    ("bg_removal.resharp.max_iter", "RESHARP's cgs iteration cap; only with RESHARP"),
543    ("bg_removal.pdf.n_cg", "PDF's CG iteration cap (PdfParams::n_cg); only with PDF"),
544    ("bg_removal.lbv.tol", "LBV's coarsest-grid stopping tolerance, radians over the echo spacing (LbvParams::tol); only with LBV"),
545    ("inversion.algorithm", "Dipole inversion (InversionAlgorithm::id)"),
546    ("inversion.star.pad", "STAR-QSM padding per side, voxels (StarParams::pad)"),
547    ("inversion.ilsqr.max_iter", "iLSQR's LSQR iteration cap (IlsqrParams::max_iter); only with iLSQR"),
548    ("inversion.ilsqr.pad_mm", "iLSQR's padding, mm (IlsqrParams::pad_mm); only with iLSQR"),
549    ("inversion.whqsm.weight", "WH-QSM's data weight: flat, max-normalised or in-vivo (WhQsmWeight); only with WH-QSM"),
550    ("inversion.star.te", "Echo time STAR-QSM converts at (EchoTime: first, last, spacing, sum, seconds); only with STAR"),
551    ("inversion.fansi.te", "Echo time FANSI nlTV/nlTGV convert at (EchoTime); only with fansi or fansi-tgv"),
552    ("inversion.ndi.te", "Echo time NDI converts at (EchoTime); only with NDI"),
553    ("inversion.l1qsm.te", "Echo time L1-QSM converts at (EchoTime); only with L1-QSM"),
554    ("inversion.whqsm.te", "Echo time WH-QSM converts at (EchoTime); only with WH-QSM"),
555    ("inversion.medi.msmv", "MEDI-mSMV: MediParams::msmv is set (bool); only with MEDI"),
556    ("inversion.medi.msmv.prefilter", "MEDI-mSMV's SMV prefilter (MsmvParams::prefilter, bool); only with MEDI-mSMV"),
557    ("inversion.medi.te", "Echo time MEDI converts at (EchoTime); only with MEDI"),
558    ("inversion.tfi.te", "Echo time TFI converts at (EchoTime); only with TFI"),
559    ("inversion.apply_heidi", "HEIDI's cone fill after the inversion (InversionConfig::apply_heidi, bool)"),
560    ("inversion.heidi.cone_threshold", "HEIDI's cone threshold, |D| (HeidiParams::cone_threshold); only with HEIDI"),
561    ("inversion.heidi.te", "Echo time HEIDI converts at (EchoTime); only with HEIDI"),
562    ("inversion.tkd.threshold", "TKD's truncation threshold, |D| (TkdParams::threshold); only with TKD"),
563    ("inversion.tsvd.threshold", "TSVD's truncation threshold, |D| (TkdParams::threshold); only with TSVD"),
564    ("inversion.qsmart.inversion", "QSMART's inner inversion (InversionAlgorithm::id); only with QSMART"),
565    ("inversion.qsmart.ilsqr_max_iter", "QSMART's iLSQR iteration count; only with QSMART"),
566    ("inversion.qsmart.ilsqr_pad_mm", "QSMART's iLSQR padding, mm; only with QSMART"),
567    ("reference", "QSM referencing (QsmReference: mean or none)"),
568    ("qsm_per_echo", "A QSM per echo, and their mean, instead of one QSM from the echo-combined field (QsmPipelineConfig::qsm_per_echo, bool)"),
569    ("tukey_window", "Taper ratio of the χ-separation toolbox's k-space Tukey window on the complex echoes, 0 = none (QsmPipelineConfig::tukey_window)"),
570    ("separation.algorithm", "χ-separation (SeparationAlgorithm::id)"),
571    ("separation.chi_sepnet.route", "χ-sepnet's input route: toolbox or native (ChiSepNetRoute::id); only with χ-sepnet"),
572    ("separation.decompose.unwrap_phase", "DECOMPOSE's phase unwrapping along the echoes (DecomposeParams::unwrap_phase, bool); only with DECOMPOSE"),
573    ("separation.decompose.chi_bound", "DECOMPOSE's bound on |χ| in the fit (DecomposeChiBound::id): ppm (a fixed bound, the authors' 0.5) or echo-spacing (π / ((2/3)·γ·B0·ΔTE_max), computed from the scan); only with DECOMPOSE"),
574    ("segmentation.inference", "SynthSeg inference: full-volume, low-memory (SynthSegInference::LOW_MEMORY) or sliding-window"),
575    ("segmentation.inference.patch", "SynthSeg window edge, voxels at 1 mm (SlidingWindow::patch)"),
576];
577
578/// Stable id of an [`EchoTime`](crate::EchoTime) choice (`Seconds` is `"seconds"`, whatever its
579/// value).
580pub fn echo_time_id(t: crate::EchoTime) -> &'static str {
581    match t {
582        crate::EchoTime::First => "first",
583        crate::EchoTime::Last => "last",
584        crate::EchoTime::Spacing => "spacing",
585        crate::EchoTime::Sum => "sum",
586        crate::EchoTime::Seconds(_) => "seconds",
587    }
588}
589
590/// Stable id of a weighted average's per-echo weight.
591pub fn weight_id(w: &crate::utils::multi_echo::B0WeightType) -> &'static str {
592    use crate::utils::multi_echo::B0WeightType as W;
593    match w {
594        W::PhaseSNR => "phase-snr",
595        W::PhaseVar => "phase-var",
596        W::Average => "average",
597        W::TEs => "tes",
598        W::Mag => "mag",
599        W::AssumedDecay { .. } => "assumed-decay",
600    }
601}
602
603/// Every HD-BET step in the mask recipe.
604fn hd_bet_steps(c: &QsmPipelineConfig) -> impl Iterator<Item = &HdBetParams> {
605    c.masking.sections.iter().flat_map(|s| std::iter::once(&s.generator).chain(&s.refinements)).filter_map(
606        |op| match op {
607            super::config::MaskOp::HdBet(p) => Some(p),
608            _ => None,
609        },
610    )
611}
612
613/// The current value(s) of `setting` in `input`: empty when the setting is not in play (its
614/// stage does not run, or its method is not selected), one value per place it is set otherwise.
615/// `None` for a path that is not in [`SETTINGS`].
616///
617/// Whether background removal and field mapping run depends on the inversion's input
618/// ([`InversionConfig::input`](super::config::InversionConfig::input)), which for TGV depends on
619/// the echo count; that difference is only between two inputs that both skip background removal,
620/// so one echo is assumed when the count is not known.
621///
622/// A per-echo reconstruction ([`QsmPipelineConfig::qsm_per_echo`]) runs every stage on one echo
623/// at a time, so its inversion's input is the one-echo input, and the settings that combine or
624/// compare echoes (the echo combination and its weighting, phase-offset removal and its sigma,
625/// bipolar correction) are not in play.
626pub fn current(input: &RuleInput, facts: &ScanFacts, setting: &str) -> Option<Vec<Current>> {
627    let c = input.pipeline;
628    let inv_input = c.inversion.input(if c.qsm_per_echo { 1 } else { facts.echo_count.unwrap_or(1) });
629    // Field mapping that combines or compares echoes: not on a per-echo reconstruction.
630    let combines = inv_input.needs_field_mapping() && !c.qsm_per_echo;
631    let one = |v: Current| vec![v];
632    let gated = |on: bool, v: Current| if on { vec![v] } else { vec![] };
633    let bg = |a: super::config::BgRemovalAlgorithm| inv_input.needs_bg_removal() && c.bg_removal.algorithm == a;
634    let vsharp = bg(super::config::BgRemovalAlgorithm::Vsharp);
635    let resharp = bg(super::config::BgRemovalAlgorithm::Resharp);
636    let ismv = bg(super::config::BgRemovalAlgorithm::Ismv);
637    let pdf = bg(super::config::BgRemovalAlgorithm::Pdf);
638    let lbv = bg(super::config::BgRemovalAlgorithm::Lbv);
639    use super::config::InversionAlgorithm as A;
640    let alg = |a: &[A]| a.contains(&c.inversion.algorithm);
641    let qsmart = alg(&[A::Qsmart]);
642    Some(match setting {
643        "masking.generator" => {
644            c.masking.sections.iter().map(|s| Current::Choice(s.generator.id())).collect()
645        }
646        "masking.hd_bet.inference" => hd_bet_steps(c)
647            .map(|p| {
648                Current::Choice(match p.inference {
649                    HdBetInference::FullVolume => "full-volume",
650                    i if i == HdBetInference::SLIDING_WINDOW => "sliding-window",
651                    i if i == HdBetInference::LOW_MEMORY => "low-memory",
652                    HdBetInference::SlidingWindow { .. } => "custom-window",
653                })
654            })
655            .collect(),
656        "field_mapping.unwrapping_algorithm" => gated(
657            inv_input.needs_field_mapping(),
658            Current::Choice(c.field_mapping.unwrapping_algorithm.id()),
659        ),
660        "field_mapping.b0_estimation" => gated(combines, Current::Choice(c.field_mapping.b0_estimation.id())),
661        "field_mapping.b0_estimation.weighting" => match &c.field_mapping.b0_estimation {
662            super::config::B0EstimationMethod::WeightedAvg { weighting } if combines => {
663                one(Current::Choice(weight_id(weighting)))
664            }
665            _ => vec![],
666        },
667        "field_mapping.phase_offset_removal" => gated(combines, Current::Bool(c.field_mapping.phase_offset_removal)),
668        "field_mapping.bipolar_correction" => gated(combines, Current::Bool(c.field_mapping.bipolar_correction)),
669        "qsm_per_echo" => one(Current::Bool(c.qsm_per_echo)),
670        "tukey_window" => one(Current::Number(c.tukey_window)),
671        "reference" => one(Current::Choice(match c.reference {
672            super::config::QsmReference::Mean => "mean",
673            super::config::QsmReference::None => "none",
674        })),
675        "field_mapping.laplacian_solver" => {
676            use crate::unwrap::LaplacianSolver as L;
677            gated(
678                inv_input.needs_field_mapping()
679                    && c.field_mapping.unwrapping_algorithm == super::config::UnwrappingAlgorithm::Laplacian,
680                Current::Choice(match c.field_mapping.laplacian_solver {
681                    L::Dct => "dct",
682                    s if s == L::FFT_DEFAULT_PAD => "fft-12",
683                    L::Fft { .. } => "fft",
684                    L::Mirror => "mirror",
685                }),
686            )
687        }
688        "field_mapping.laplacian_unmasked" => gated(
689            inv_input.needs_field_mapping()
690                && c.field_mapping.unwrapping_algorithm == super::config::UnwrappingAlgorithm::Laplacian,
691            Current::Bool(c.field_mapping.laplacian_unmasked),
692        ),
693        "bg_removal.vsharp.deconvolve" => gated(vsharp, Current::Bool(c.bg_removal.vsharp.deconvolve)),
694        "bg_removal.vsharp.max_radius" => gated(vsharp, Current::Number(c.bg_removal.vsharp.max_radius)),
695        "bg_removal.vsharp.min_radius" => gated(vsharp, Current::Number(c.bg_removal.vsharp.min_radius)),
696        "bg_removal.vsharp.radius_step" => gated(vsharp, Current::Number(c.bg_removal.vsharp.radius_step)),
697        "field_mapping.phase_offset_sigma_mm" => gated(
698            combines && c.field_mapping.phase_offset_removal,
699            Current::Vector(c.field_mapping.phase_offset_sigma_mm.to_vec()),
700        ),
701        "inversion.ilsqr.max_iter" => gated(alg(&[A::Ilsqr]), Current::Number(c.inversion.ilsqr.max_iter as f64)),
702        "inversion.ilsqr.pad_mm" => gated(alg(&[A::Ilsqr]), Current::Number(c.inversion.ilsqr.pad_mm)),
703        "bg_removal.vsharp.grid" => gated(
704            vsharp,
705            Current::Choice(match c.bg_removal.vsharp.grid {
706                crate::bgremove::VsharpGrid::Native => "native",
707                crate::bgremove::VsharpGrid::IsotropicFinest => "isotropic-finest",
708                crate::bgremove::VsharpGrid::Isotropic { spacing_mm: 1.0 } => "isotropic-1mm",
709                crate::bgremove::VsharpGrid::Isotropic { .. } => "isotropic",
710            }),
711        ),
712        "bg_removal.ismv.variant" => gated(ismv, Current::Choice(c.bg_removal.ismv.variant.id())),
713        "bg_removal.ismv.tol" => gated(ismv, Current::Number(c.bg_removal.ismv.tol)),
714        "bg_removal.ismv.max_iter" => gated(
715            ismv,
716            Current::Number(c.bg_removal.ismv.max_iter.map_or(f64::INFINITY, |n| n as f64)),
717        ),
718        "bg_removal.resharp.radius" => gated(resharp, Current::Number(c.bg_removal.resharp.radius)),
719        "bg_removal.resharp.max_iter" => gated(resharp, Current::Number(c.bg_removal.resharp.max_iter as f64)),
720        "bg_removal.pdf.n_cg" => gated(pdf, Current::Number(c.bg_removal.pdf.n_cg as f64)),
721        "bg_removal.lbv.tol" => gated(lbv, Current::Number(c.bg_removal.lbv.tol)),
722        "inversion.whqsm.weight" => gated(
723            alg(&[A::Whqsm]),
724            Current::Choice(match c.inversion.whqsm.weight {
725                crate::inversion::WhQsmWeight::Flat => "flat",
726                crate::inversion::WhQsmWeight::MaxNormalised => "max-normalised",
727                crate::inversion::WhQsmWeight::InVivo => "in-vivo",
728            }),
729        ),
730        "inversion.star.te" => gated(alg(&[A::Star]), Current::Choice(echo_time_id(c.inversion.star.te))),
731        "inversion.fansi.te" => {
732            gated(alg(&[A::Fansi, A::FansiTgv]), Current::Choice(echo_time_id(c.inversion.fansi.te)))
733        }
734        "inversion.ndi.te" => gated(alg(&[A::Ndi]), Current::Choice(echo_time_id(c.inversion.ndi.te))),
735        "inversion.l1qsm.te" => gated(alg(&[A::L1qsm]), Current::Choice(echo_time_id(c.inversion.l1qsm.te))),
736        "inversion.whqsm.te" => gated(alg(&[A::Whqsm]), Current::Choice(echo_time_id(c.inversion.whqsm.te))),
737        "inversion.medi.msmv" => gated(alg(&[A::Medi]), Current::Bool(c.inversion.medi.msmv.is_some())),
738        "inversion.medi.msmv.prefilter" => match &c.inversion.medi.msmv {
739            Some(m) if alg(&[A::Medi]) => one(Current::Bool(m.prefilter)),
740            _ => vec![],
741        },
742        "inversion.medi.te" => gated(alg(&[A::Medi]), Current::Choice(echo_time_id(c.inversion.medi.te))),
743        "inversion.tfi.te" => gated(alg(&[A::Tfi]), Current::Choice(echo_time_id(c.inversion.tfi.te))),
744        "inversion.apply_heidi" => one(Current::Bool(c.inversion.apply_heidi)),
745        "inversion.heidi.cone_threshold" => gated(c.inversion.apply_heidi, Current::Number(c.inversion.heidi.cone_threshold)),
746        "inversion.heidi.te" => gated(c.inversion.apply_heidi, Current::Choice(echo_time_id(c.inversion.heidi.te))),
747        "inversion.tkd.threshold" => gated(alg(&[A::Tkd]), Current::Number(c.inversion.tkd.threshold)),
748        "inversion.tsvd.threshold" => gated(alg(&[A::Tsvd]), Current::Number(c.inversion.tsvd.threshold)),
749        "inversion.qsmart.inversion" => gated(qsmart, Current::Choice(c.inversion.qsmart.inversion.id())),
750        "inversion.qsmart.ilsqr_max_iter" => {
751            gated(qsmart, Current::Number(c.inversion.qsmart.ilsqr_max_iter as f64))
752        }
753        "inversion.qsmart.ilsqr_pad_mm" => gated(qsmart, Current::Number(c.inversion.qsmart.ilsqr_pad_mm)),
754        "bg_removal.algorithm" => gated(
755            inv_input.needs_bg_removal(),
756            Current::Choice(c.bg_removal.algorithm.id()),
757        ),
758        "inversion.algorithm" => one(Current::Choice(c.inversion.algorithm.id())),
759        "inversion.star.pad" => gated(
760            c.inversion.algorithm == super::config::InversionAlgorithm::Star,
761            Current::Vector(c.inversion.star.pad.iter().map(|&p| p as f64).collect()),
762        ),
763        "separation.algorithm" => {
764            input.separation.map(|s| Current::Choice(s.algorithm.id())).into_iter().collect()
765        }
766        "separation.chi_sepnet.route" => input
767            .separation
768            .filter(|s| s.algorithm == super::config::SeparationAlgorithm::ChiSepNet)
769            .map(|s| Current::Choice(s.chi_sepnet.route.id()))
770            .into_iter()
771            .collect(),
772        "separation.decompose.unwrap_phase" => input
773            .separation
774            .filter(|s| s.algorithm == super::config::SeparationAlgorithm::Decompose)
775            .map(|s| Current::Bool(s.decompose.unwrap_phase))
776            .into_iter()
777            .collect(),
778        "separation.decompose.chi_bound" => input
779            .separation
780            .filter(|s| s.algorithm == super::config::SeparationAlgorithm::Decompose)
781            .map(|s| Current::Choice(s.decompose.chi_bound.id()))
782            .into_iter()
783            .collect(),
784        "segmentation.inference" => input
785            .segmentation
786            .map(|s| {
787                Current::Choice(match s.inference {
788                    SynthSegInference::FullVolume => "full-volume",
789                    i if i == SynthSegInference::LOW_MEMORY => "low-memory",
790                    SynthSegInference::SlidingWindow { .. } => "sliding-window",
791                })
792            })
793            .into_iter()
794            .collect(),
795        "segmentation.inference.patch" => input
796            .segmentation
797            .and_then(|s| match s.inference {
798                SynthSegInference::SlidingWindow { patch, .. } => Some(Current::Number(patch as f64)),
799                SynthSegInference::FullVolume => None,
800            })
801            .into_iter()
802            .collect(),
803        _ => return None,
804    })
805}
806
807// =========================================================================
808// Evaluation
809// =========================================================================
810
811fn holds(cond: &Condition, input: &RuleInput, facts: &ScanFacts) -> bool {
812    match cond {
813        Condition::Setting { setting, is } => {
814            current(input, facts, setting).unwrap_or_default().iter().any(|v| is.passes(v))
815        }
816        Condition::Fact { fact, is } => facts.fact(*fact, input).is_some_and(|v| is.passes(&v)),
817    }
818}
819
820/// Check one rule: `Some` finding when its conditions hold and its setting does not pass.
821pub fn check(rule: &'static Rule, input: &RuleInput, facts: &ScanFacts) -> Option<Finding> {
822    if !rule.when.iter().all(|c| holds(c, input, facts)) {
823        return None;
824    }
825    let current = current(input, facts, rule.setting).unwrap_or_default();
826    if current.is_empty() || current.iter().all(|v| rule.expect.passes(v)) {
827        return None;
828    }
829    Some(Finding { rule, current })
830}
831
832/// Every finding for `input` on a scan with `facts`, in [`RULES`] order.
833pub fn evaluate(input: &RuleInput, facts: &ScanFacts) -> Vec<Finding> {
834    RULES.iter().filter_map(|r| check(r, input, facts)).collect()
835}
836
837
838// =========================================================================
839// The rules
840// =========================================================================
841
842use Condition as C;
843use Value::{Bool, Choice, Number};
844
845const fn method(setting: &'static str, values: &'static [Value]) -> Condition {
846    C::Setting { setting, is: Test::OneOf { values } }
847}
848const fn inv(values: &'static [Value]) -> Condition {
849    method("inversion.algorithm", values)
850}
851const fn bg(values: &'static [Value]) -> Condition {
852    method("bg_removal.algorithm", values)
853}
854const fn sep(values: &'static [Value]) -> Condition {
855    method("separation.algorithm", values)
856}
857const fn fact_at_least(fact: Fact, min: f64) -> Condition {
858    C::Fact { fact, is: Test::Range { min: Some(min), max: None } }
859}
860const fn fact_at_most(fact: Fact, max: f64) -> Condition {
861    C::Fact { fact, is: Test::Range { min: None, max: Some(max) } }
862}
863const fn manufacturer(values: &'static [Value]) -> Condition {
864    C::Fact { fact: Fact::Manufacturer, is: Test::OneOf { values } }
865}
866const ON_WASM: Condition = C::Fact { fact: Fact::Target, is: Test::OneOf { values: &[Choice("wasm")] } };
867const MULTI_ECHO: Condition = fact_at_least(Fact::EchoCount, 2.0);
868/// The field strength from which `decompose.unwrap-7t` and `decompose.chi-bound-7t` fire: 7 T scanners, including those that
869/// report 6.98 T (Siemens), and nothing at 3 T or below.
870const DECOMPOSE_UNWRAP_FROM_T: f64 = 6.5;
871
872/// A choice of `strength` on `basis`: `setting` should be one of `values`, the first being the
873/// default.
874const fn one_of(
875    id: &'static str,
876    when: &'static [Condition],
877    setting: &'static str,
878    values: &'static [Value],
879    strength: Strength,
880    basis: Basis,
881    reason: &'static str,
882    source: &'static str,
883) -> Rule {
884    Rule {
885        id,
886        when,
887        setting,
888        expect: Test::OneOf { values },
889        default: values[0],
890        unit: Unit::None,
891        strength,
892        basis,
893        reason,
894        source,
895    }
896}
897
898/// A reasoned recommendation the authors make: they say why it is needed or better, and
899/// `reason` says it.
900const fn recommend(
901    id: &'static str,
902    when: &'static [Condition],
903    setting: &'static str,
904    values: &'static [Value],
905    reason: &'static str,
906    source: &'static str,
907) -> Rule {
908    one_of(id, when, setting, values, Strength::Recommended, Basis::Stated, reason, source)
909}
910
911/// What QSM.rs measured to be better (recorded in the method's docs).
912const fn measured_better(
913    id: &'static str,
914    when: &'static [Condition],
915    setting: &'static str,
916    values: &'static [Value],
917    reason: &'static str,
918    source: &'static str,
919) -> Rule {
920    one_of(id, when, setting, values, Strength::Recommended, Basis::Measured, reason, source)
921}
922
923/// The original authors' setting: what their code or paper does, without saying it is needed or
924/// better.
925const fn reference(
926    id: &'static str,
927    when: &'static [Condition],
928    setting: &'static str,
929    values: &'static [Value],
930    reason: &'static str,
931    source: &'static str,
932) -> Rule {
933    one_of(id, when, setting, values, Strength::Reference, Basis::ReferenceChoice, reason, source)
934}
935
936/// The original authors' setting as their README or a code comment instructs it ("do X", "we
937/// recommend X"), without saying why it is needed or better: still [`Kind::Reference`].
938const fn reference_stated(
939    id: &'static str,
940    when: &'static [Condition],
941    setting: &'static str,
942    values: &'static [Value],
943    reason: &'static str,
944    source: &'static str,
945) -> Rule {
946    one_of(id, when, setting, values, Strength::Reference, Basis::Stated, reason, source)
947}
948
949/// A discouraged choice: `setting` should be none of `avoid`; `default` replaces it.
950const fn discourage(
951    id: &'static str,
952    when: &'static [Condition],
953    setting: &'static str,
954    avoid: &'static [Value],
955    default: Value,
956    basis: Basis,
957    reason: &'static str,
958    source: &'static str,
959) -> Rule {
960    Rule {
961        id,
962        when,
963        setting,
964        expect: Test::NoneOf { values: avoid },
965        default,
966        unit: Unit::None,
967        strength: Strength::Discouraged,
968        basis,
969        reason,
970        source,
971    }
972}
973
974const BET: &[Value] = &[Choice("bet")];
975/// The CG iteration count `pdf.cg-iterations` suggests (measured; see `PdfParams::n_cg`).
976const PDF_SUGGESTED_N_CG: f64 = 300.0;
977const PDF: &[Value] = &[Choice("pdf")];
978const LBV: &[Value] = &[Choice("lbv")];
979const VSHARP: &[Value] = &[Choice("vsharp")];
980const LAPLACIAN: &[Value] = &[Choice("laplacian")];
981const ROMEO: &[Value] = &[Choice("romeo")];
982const COMPLEX_FIT: &[Value] = &[Choice("complex-fit")];
983const WEIGHTED_AVG: &[Value] = &[Choice("weighted-avg")];
984const CHI_SEP_TOOLBOX: &[Value] = &[Choice("chi-sep-ilsqr"), Choice("chi-sep-medi"), Choice("chi-sepnet")];
985/// χ-sepnet's toolbox route (`ChiSepNetRoute::Toolbox`).
986const CHI_SEPNET_TOOLBOX_ROUTE: Condition = method("separation.chi_sepnet.route", &[Choice("toolbox")]);
987/// The taper ratio the χ-separation toolbox recommends for Siemens and GE data.
988pub const CHISEP_TUKEY_SIEMENS_GE: f64 = 0.4;
989const TUKEY_SOURCE: &str = "χ-separation toolbox v1.1.3 Chisep_script.m:69-73 \
990    (\"tukey_fac = 0.4; % Recommendation: Siemens, GE: 0.4, Philips: 0\"); QSM.rs utils::tukey_window docs";
991const TUKEY_SIEMENS_GE_REASON: &str = "The χ-separation toolbox Tukey-windows the complex echoes in \
992    k-space before everything else, with a taper ratio of 0.4 for Siemens and GE data";
993const TUKEY_PHILIPS_REASON: &str = "The χ-separation toolbox uses no Tukey windowing (a taper ratio of 0) \
994    for Philips data";
995/// The χ-separation toolbox's iterative methods (χ-sepnet has two routes; only its toolbox route
996/// is the toolbox's pipeline).
997const CHI_SEP_TOOLBOX_ITERATIVE: &[Value] = &[Choice("chi-sep-ilsqr"), Choice("chi-sep-medi")];
998/// QSMnetME: the χ-separation toolbox's QSM network, trained on that toolbox's pipeline.
999const QSMNETME: &[Value] = &[Choice("qsmnetme")];
1000const STI: &[Value] = &[Choice("star"), Choice("ilsqr")];
1001/// HEIDI's cone fill is on (`inversion.apply_heidi`): the trigger of the `heidi.*` rules.
1002const HEIDI_ON: Condition = method("inversion.apply_heidi", &[Bool(true)]);
1003/// Inversions with no local field to fill the cone from (the total-field and phase methods).
1004const HEIDI_NO_LOCAL_FIELD: &[Value] = &[
1005    Choice("tfi"), Choice("tgv"), Choice("qsmart"), Choice("autoqsm"), Choice("nextqsm"),
1006    Choice("iqsm"), Choice("iqsm-plus"),
1007];
1008/// Seeds whose region outside the cone is near data-consistent (the HEIDI module docs, *Any
1009/// seed*), but for LSQR (the reference) and RTS (`heidi.seed-rts`).
1010const HEIDI_UNREGULARISED_SEEDS: &[Value] =
1011    &[Choice("tkd"), Choice("tsvd"), Choice("tikhonov"), Choice("ndi")];
1012/// Seeds whose regularisation outside the cone HEIDI would keep.
1013const HEIDI_REGULARISED_SEEDS: &[Value] = &[
1014    Choice("medi"), Choice("tv"), Choice("nltv"), Choice("fansi"), Choice("fansi-tgv"),
1015    Choice("l1qsm"), Choice("whqsm"), Choice("hdqsm"), Choice("amp-pe"), Choice("ilsqr"),
1016    Choice("star"), Choice("xqsm"), Choice("qsmnet"), Choice("qsmnet-plus"), Choice("qsmnetme"),
1017    Choice("qsmgan"), Choice("ir2qsm"), Choice("lpcnn"), Choice("modl-qsm"),
1018];
1019/// Upstream's error when a truncating seed's threshold is above the cone's.
1020const HEIDI_CONE_SOURCE: &str = "qsm_heidi computesusceptibility_heidi.m L922-923 (4592631); QSM.rs heidi.rs docs (Any seed)";
1021
1022/// HEIDI's default cone threshold: the `heidi.*-cone` rules are exact for it;
1023/// [`heidi_seed_problem`](super::heidi_seed_problem) checks any.
1024const HEIDI_DEFAULT_CONE: Condition = C::Setting {
1025    setting: "inversion.heidi.cone_threshold",
1026    is: Test::Range { min: Some(0.1), max: Some(0.1) },
1027};
1028
1029/// `heidi.<method>-cone`: a truncating seed's threshold must not be above HEIDI's cone threshold.
1030const fn heidi_truncation(id: &'static str, when: &'static [Condition], setting: &'static str) -> Rule {
1031    Rule {
1032        id,
1033        when,
1034        setting,
1035        expect: Test::Range { min: None, max: Some(0.1) },
1036        default: Number(0.1),
1037        unit: Unit::None,
1038        strength: Strength::Required,
1039        basis: Basis::Stated,
1040        reason: "Coefficients the seed truncated (|D| below its threshold) would be kept as data \
1041                 where they lie outside HEIDI's cone; upstream stops with an error",
1042        source: HEIDI_CONE_SOURCE,
1043    }
1044}
1045
1046const FANSI_FAMILY: &[Value] =
1047    &[Choice("ndi"), Choice("fansi"), Choice("fansi-tgv"), Choice("l1qsm"), Choice("whqsm")];
1048
1049/// The catalogue. Each rule's reason and source are also recorded in the docs of the method it
1050/// is about (its "Reference pipeline" section). Rules that depend on work not yet merged are
1051/// listed in issue #289, not here.
1052pub static RULES: &[Rule] = &[
1053    // ---------------------------------------------------------------- Brain masking
1054    reference("medi.mask-bet", &[inv(&[Choice("medi")])], "masking.generator", BET,
1055        "MEDI's pipeline masks with FSL BET (bet2, f 0.5) on the root-sum-of-squares magnitude",
1056        "MEDI toolbox 2024.11.26 README.m:80,118; functions/bet2/bet2.cpp:136"),
1057    reference("tfi.mask-bet", &[inv(&[Choice("tfi")])], "masking.generator", BET,
1058        "TFI's in-vivo ROI is a BET brain mask",
1059        "Liu et al. 2017 MRM 79:2950 (TFI), Methods"),
1060    reference("pdf.mask-bet", &[bg(PDF)], "masking.generator", BET,
1061        "MEDI's PDF runs inside the BET mask",
1062        "MEDI toolbox 2024.11.26 README.m:118,124"),
1063    reference("amp-pe.mask-bet", &[inv(&[Choice("amp-pe")])], "masking.generator", BET,
1064        "AMP-PE's pipeline masks with BET (0.5) on the RSS magnitude, then erodes 2 mm",
1065        "QSM_AMP_PE qsm_multi_echo_combined.m L71-81"),
1066    reference_stated("qsmnet.mask-bet", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")])], "masking.generator", BET,
1067        "QSMnet's README instructs BET (FSL) brain extraction before its input is made",
1068        "SNU-LIST/QSMnet README.md:64-68 (Process flow)"),
1069    reference("qsmgan.mask-bet", &[inv(&[Choice("qsmgan")])], "masking.generator", BET,
1070        "QSMGAN's training inputs were masked with FSL BET",
1071        "Chen et al. 2020 NeuroImage 207:116389 (QSMGAN), Methods"),
1072    reference("lpcnn.mask-bet", &[inv(&[Choice("lpcnn")])], "masking.generator", BET,
1073        "LPCNN's data were masked with FSL BET",
1074        "Lai et al. MICCAI 2020 (LPCNN, arXiv:2008.05024), Dataset Acquisition and Preprocessing"),
1075    reference("nextqsm.mask-bet", &[inv(&[Choice("nextqsm")])], "masking.generator", BET,
1076        "NeXtQSM's in-vivo pipeline masks with FSL BET",
1077        "Cognolato et al. 2023 Med. Image Anal. 84:102700 (NeXtQSM), §3.2"),
1078    reference("iqsm.mask-bet", &[inv(&[Choice("iqsm"), Choice("iqsm-plus")])], "masking.generator", BET,
1079        "iQSM's default mask is bet2 -f 0.5 on the first-echo magnitude",
1080        "sunhongfu/iQSM run.py:499-506, bet2_utils.py:47,64"),
1081    reference("bfrnet.mask-bet", &[bg(&[Choice("bfrnet")])], "masking.generator", BET,
1082        "BFRnet's in-vivo masks are bet2 -f 0.2 -w 2, eroded 3 voxels",
1083        "Zhu et al. 2023 Z. Med. Phys. (BFRnet), Methods"),
1084    reference("chi-sep.mask-bet", &[sep(CHI_SEP_TOOLBOX)], "masking.generator", BET,
1085        "The χ-separation toolbox and χ-sepnet mask with BET",
1086        "χ-separation toolbox Chisep_script.m:79-82; Kim et al. 2025 (χ-sepnet, arXiv:2409.14030) §2.1.1"),
1087    reference("qsmnetme.mask-bet", &[inv(QSMNETME)], "masking.generator", BET,
1088        "QSMnetME runs in the χ-separation toolbox, which masks with BET",
1089        "χ-separation toolbox Chisep_script.m:79-82; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1090    reference("qsmart.mask-bet", &[inv(&[Choice("qsmart")])], "masking.generator", BET,
1091        "QSMART's authors mask with FSL bet2 -f 0.2 on the N4-corrected first-echo magnitude",
1092        "Yaghmaie et al. 2021 NeuroImage 231:117701 (QSMART) §3.1; QSMART brainmask.m l. 7-33"),
1093    reference("decompose.mask-bet", &[sep(&[Choice("decompose")])], "masking.generator", BET,
1094        "DECOMPOSE's pipeline masks with FSL BET",
1095        "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1096    Rule {
1097        id: "mindgrab.wasm-heap",
1098        when: &[method("masking.generator", &[Choice("mindgrab")]), ON_WASM],
1099        setting: "masking.generator",
1100        expect: Test::NoneOf { values: &[Choice("mindgrab")] },
1101        default: Choice("bet"),
1102        unit: Unit::None,
1103        strength: Strength::Required,
1104        basis: Basis::Measured,
1105        reason: "MindGrab always runs a whole 256³ volume and cannot be tiled; its WASM peak \
1106                 (about 5.6 GB) exceeds a 4 GiB heap",
1107        source: "QSM.rs models registry (mindgrab memory figures) and bet::mindgrab docs",
1108    },
1109    Rule {
1110        id: "hd-bet.wasm-heap",
1111        when: &[
1112            method("masking.generator", &[Choice("hd-bet")]),
1113            ON_WASM,
1114            fact_at_least(Fact::HdBetWasmBytes, (WASM32_HEAP_BYTES + 1) as f64),
1115        ],
1116        setting: "masking.hd_bet.inference",
1117        expect: Test::OneOf { values: &[Choice("low-memory")] },
1118        default: Choice("low-memory"),
1119        unit: Unit::None,
1120        strength: Strength::Required,
1121        basis: Basis::Measured,
1122        reason: "The configured HD-BET step needs more than a 4 GiB WASM heap on this scan; \
1123                 (128, 128, 64) sliding windows peak at about 1.6 GB and agree with the trained \
1124                 patch at Dice ≈0.99",
1125        source: "QSM.rs bet::hdbet, HdBetParams::low_memory and hd_bet_memory_bytes (#280)",
1126    },
1127    // ---------------------------------------------------------------- Segmentation
1128    Rule {
1129        id: "synthseg.wasm-windows",
1130        when: &[method("segmentation.inference", &[Choice("full-volume")]), ON_WASM],
1131        setting: "segmentation.inference",
1132        expect: Test::NoneOf { values: &[Choice("full-volume")] },
1133        default: Choice("low-memory"),
1134        unit: Unit::None,
1135        strength: Strength::Required,
1136        basis: Basis::Measured,
1137        reason: "SynthSeg's whole-volume pass needs 6–12 GB on 1 mm head scans; no whole-volume \
1138                 setting fits a 4 GB WASM heap",
1139        source: "QSM.rs segment::synthseg module docs and SynthSegInference::LOW_MEMORY (#281)",
1140    },
1141    Rule {
1142        id: "synthseg.wasm-128-window",
1143        when: &[
1144            C::Setting {
1145                setting: "segmentation.inference.patch",
1146                is: Test::Range { min: Some(128.0), max: None },
1147            },
1148            ON_WASM,
1149            fact_at_least(Fact::SynthSegNetworkVoxels, (192 * 256 * 128 + 1) as f64),
1150        ],
1151        setting: "segmentation.inference.patch",
1152        expect: Test::Range { min: None, max: Some(96.0) },
1153        default: Number(96.0),
1154        unit: Unit::Voxels,
1155        strength: Strength::Required,
1156        basis: Basis::Measured,
1157        reason: "128³ SynthSeg windows took a 192×256×128 grid to 3.83 GB of the 4 GB WASM heap; \
1158                 a larger grid does not fit, 96³ windows do",
1159        source: "QSM.rs SynthSegInference::LOW_MEMORY docs (#281)",
1160    },
1161    // ---------------------------------------------------------------- Phase unwrapping
1162    discourage("fansi.no-laplacian-multi-echo", &[inv(FANSI_FAMILY), MULTI_ECHO],
1163        "field_mapping.unwrapping_algorithm", LAPLACIAN, Choice("romeo"), Basis::Stated,
1164        "FANSI's authors advise against Laplacian unwrapping for multi-echo data: it removes \
1165         harmonic components of the phase",
1166        "FANSI-toolbox script_DICOM_Philips.m L196-197 (93044a3)"),
1167    reference_stated("qsmnet.unwrap-laplacian", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")])],
1168        "field_mapping.unwrapping_algorithm", LAPLACIAN,
1169        "QSMnet's README instructs Laplacian phase unwrapping (STI Suite)",
1170        "SNU-LIST/QSMnet README.md:64-68 (Process flow)"),
1171    reference("autoqsm.unwrap-laplacian", &[inv(&[Choice("autoqsm")])],
1172        "field_mapping.unwrapping_algorithm", LAPLACIAN,
1173        "AutoQSM was trained on Laplacian-unwrapped total phase",
1174        "Wei et al. 2019 NeuroImage 201:116064 (AutoQSM), Methods"),
1175    reference("qsmgan.unwrap-laplacian", &[inv(&[Choice("qsmgan")])],
1176        "field_mapping.unwrapping_algorithm", LAPLACIAN,
1177        "QSMGAN's training inputs were Laplacian-unwrapped",
1178        "Chen et al. 2020 NeuroImage 207:116389 (QSMGAN), Methods"),
1179    reference("decompose.unwrap-laplacian", &[sep(&[Choice("decompose")])],
1180        "field_mapping.unwrapping_algorithm", LAPLACIAN,
1181        "DECOMPOSE's pipeline uses Laplacian unwrapping",
1182        "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1183    reference("chi-sep.unwrap-romeo", &[sep(CHI_SEP_TOOLBOX)],
1184        "field_mapping.unwrapping_algorithm", ROMEO,
1185        "The χ-separation toolbox and χ-sepnet unwrap each echo with ROMEO",
1186        "χ-separation toolbox Chisep_script.m:99-111; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1187    reference("qsmnetme.unwrap-romeo", &[inv(QSMNETME)],
1188        "field_mapping.unwrapping_algorithm", ROMEO,
1189        "QSMnetME's toolbox unwraps each echo with ROMEO",
1190        "χ-separation toolbox Chisep_script.m:99-111; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1191    reference("sti.unwrap-laplacian", &[inv(STI)], "field_mapping.unwrapping_algorithm", LAPLACIAN,
1192        "STI Suite's QSM_GUI unwraps each echo with MRPhaseUnwrap (Laplacian)",
1193        "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs laplacian.rs docs)"),
1194    reference("sti.laplacian-solver",
1195        &[inv(STI), method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1196        "field_mapping.laplacian_solver", &[Choice("fft-12")],
1197        "MRPhaseUnwrap is the FFT Schofield-Zhu solver with 12 voxels of padding",
1198        "STI Suite 3.0 MRPhaseUnwrap, probed (recorded in QSM.rs LaplacianSolver::Fft docs)"),
1199    reference("sti.unwrap-unmasked",
1200        &[inv(STI), method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1201        "field_mapping.laplacian_unmasked", &[Bool(true)],
1202        "STI Suite's QSM_GUI gives MRPhaseUnwrap the raw wrapped phase, not the phase masked first",
1203        "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs laplacian.rs docs)"),
1204    reference("qsmart.unwrap-laplacian", &[inv(&[Choice("qsmart")])],
1205        "field_mapping.unwrapping_algorithm", LAPLACIAN,
1206        "QSMART unwraps each echo with Laplacian unwrapping (lapunwrap.m)",
1207        "QSMART Demo_QSMART.m l. 32; unwrap_phase.m l. 5-10"),
1208    reference("qsmart.laplacian-solver",
1209        &[inv(&[Choice("qsmart")]), method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1210        "field_mapping.laplacian_solver", &[Choice("mirror")],
1211        "QSMART's lapunwrap.m solves on the mirrored volume",
1212        "QSMART unwrap_phase.m l. 5-10, lapunwrap.m"),
1213    reference("qsmart.unwrap-unmasked",
1214        &[inv(&[Choice("qsmart")]), method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1215        "field_mapping.laplacian_unmasked", &[Bool(true)],
1216        "QSMART unwraps each echo's raw phase over the whole volume, and its echo fit fits every voxel",
1217        "QSMART unwrap_phase.m l. 5-10; echofit.m"),
1218    // ---------------------------------------------------------------- Field mapping
1219    reference("qsmart.field-echofit", &[inv(&[Choice("qsmart")])],
1220        "field_mapping.b0_estimation", &[Choice("qsmart-echofit")],
1221        "QSMART's total field and reliability map R_0 come from echofit.m",
1222        "QSMART QSMART.m l. 26; echofit.m"),
1223    reference("sti.echo-combination", &[inv(STI), MULTI_ECHO],
1224        "field_mapping.b0_estimation", WEIGHTED_AVG,
1225        "STI Suite's QSM_GUI sums the unwrapped echoes and divides by the summed echo times",
1226        "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs star.rs docs)"),
1227    reference("sti.echo-weights", &[inv(STI), MULTI_ECHO],
1228        "field_mapping.b0_estimation.weighting", &[Choice("tes")],
1229        "Σφ/ΣTE is the TE-weighted mean of φ/TE",
1230        "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs star.rs docs)"),
1231    recommend("field-mapping.phase-offsets",
1232        &[method("field_mapping.b0_estimation", WEIGHTED_AVG),
1233          method("field_mapping.unwrapping_algorithm", ROMEO), MULTI_ECHO],
1234        "field_mapping.phase_offset_removal", &[Bool(true)],
1235        "The B0 calculation's authors state that phase offsets have to be removed before it; \
1236         ROMEO removes them (MCPC-3D-S) whenever it computes B0 from multi-echo data",
1237        "MriResearchTools.jl romeofunctions.jl:3-7; ROMEO.jl caller.jl:80-91"),
1238    Rule {
1239        id: "field-mapping.phase-offset-sigma",
1240        when: &[method("field_mapping.phase_offset_removal", &[Bool(true)])],
1241        setting: "field_mapping.phase_offset_sigma_mm",
1242        expect: Test::OneOf { values: &[Value::Vector(&super::config::ROMEO_CLI_PHASE_OFFSET_SIGMA_MM)] },
1243        default: Value::Vector(&super::config::ROMEO_CLI_PHASE_OFFSET_SIGMA_MM),
1244        unit: Unit::Mm,
1245        strength: Strength::Reference,
1246        basis: Basis::ReferenceChoice,
1247        reason: "The ROMEO command line smooths the MCPC-3D-S phase offsets with σ = 7 mm per axis",
1248        source: "ROMEO.jl 08776d4 argparse.jl l. 79-84, 217-224; caller.jl l. 137-138 (#293)",
1249    },
1250    discourage("field-mapping.bipolar-needs-three-echoes",
1251        &[method("field_mapping.bipolar_correction", &[Bool(true)]), fact_at_most(Fact::EchoCount, 2.0)],
1252        "field_mapping.bipolar_correction", &[Bool(true)], Bool(false), Basis::Stated,
1253        "Bipolar correction needs echoes 1–3; with fewer it is skipped",
1254        "ROMEO.jl argparse.jl:71-76; MriResearchTools.jl mcpc3ds.jl:110-119"),
1255    discourage("field-mapping.bipolar-not-with-laplacian",
1256        &[method("field_mapping.bipolar_correction", &[Bool(true)]),
1257          method("field_mapping.unwrapping_algorithm", LAPLACIAN)],
1258        "field_mapping.bipolar_correction", &[Bool(true)], Bool(false), Basis::Implementation,
1259        "Bipolar correction is not applied on the Laplacian path",
1260        "QSM.rs pipeline::field_mapping docs"),
1261    // MEDI's README.m unwraps Fit_ppm_complex's field with unwrapPhase. ROMEO stays the default
1262    // unwrapper (QSMxT's too); this is only the authors' pairing, offered when the complex fit is
1263    // chosen and never preselected (2026-11-04).
1264    reference("complex-fit.unwrap-region-growing", &[method("field_mapping.b0_estimation", COMPLEX_FIT)],
1265        "field_mapping.unwrapping_algorithm", &[Choice("medi-region-growing")],
1266        "MEDI's pipeline unwraps Fit_ppm_complex's field with its region-growing unwrapPhase \
1267         (mexUnwrap.c), on the root-sum-of-squares magnitude",
1268        "MEDI toolbox 2024.11.26 README.m:80-95; functions/unwrapPhase.m, mexUnwrap.c (QSM.rs \
1269         unwrap::medi_region_growing docs)"),
1270    reference("medi.field-complex-fit", &[inv(&[Choice("medi")])],
1271        "field_mapping.b0_estimation", COMPLEX_FIT,
1272        "MEDI's field map and its noise weight N_std come from Fit_ppm_complex",
1273        "MEDI toolbox 2024.11.26 README.m:87,140; dataterm_mask.m:23-27"),
1274    reference("pdf.field-complex-fit", &[bg(PDF)],
1275        "field_mapping.b0_estimation", COMPLEX_FIT,
1276        "PDF is weighted by Fit_ppm_complex's noise map N_std",
1277        "MEDI toolbox 2024.11.26 README.m:87,124; PDF.m:74-76"),
1278    reference("r2star-qsm.field-complex-fit", &[sep(&[Choice("r2star-qsm")])],
1279        "field_mapping.b0_estimation", COMPLEX_FIT,
1280        "R2*-QSM's field was a nonlinear complex fit",
1281        "Dimov et al. 2022 Tomography 8:1544, Methods"),
1282    reference("amp-pe.echo-combination", &[inv(&[Choice("amp-pe")]), MULTI_ECHO],
1283        "field_mapping.b0_estimation", WEIGHTED_AVG,
1284        "AMP-PE's pipeline averages φ/TE over the echoes",
1285        "QSM_AMP_PE qsm_multi_echo_combined.m L100-104"),
1286    reference("amp-pe.echo-weights", &[inv(&[Choice("amp-pe")]), MULTI_ECHO],
1287        "field_mapping.b0_estimation.weighting", &[Choice("average")],
1288        "AMP-PE's average of φ/TE is unweighted",
1289        "QSM_AMP_PE qsm_multi_echo_combined.m L100-104"),
1290    reference("qsmnet.echo-combination", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")]), MULTI_ECHO],
1291        "field_mapping.b0_estimation", WEIGHTED_AVG,
1292        "QSMnet's input is the summed phase over the summed echo times",
1293        "SNU-LIST/QSMnet save_input_data_for_QSMnet.m:22"),
1294    reference("qsmnet.echo-weights", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")]), MULTI_ECHO],
1295        "field_mapping.b0_estimation.weighting", &[Choice("tes")],
1296        "Σφ/ΣTE is the TE-weighted mean of φ/TE",
1297        "SNU-LIST/QSMnet save_input_data_for_QSMnet.m:22"),
1298    reference("autoqsm.echo-combination", &[inv(&[Choice("autoqsm")]), MULTI_ECHO],
1299        "field_mapping.b0_estimation", WEIGHTED_AVG,
1300        "AutoQSM's input is ψ = Σφ/(γB0ΣTE)",
1301        "Wei et al. 2019 NeuroImage 201:116064 (AutoQSM), Methods"),
1302    reference("autoqsm.echo-weights", &[inv(&[Choice("autoqsm")]), MULTI_ECHO],
1303        "field_mapping.b0_estimation.weighting", &[Choice("tes")],
1304        "Σφ/ΣTE is the TE-weighted mean of φ/TE",
1305        "Wei et al. 2019 NeuroImage 201:116064 (AutoQSM), Methods"),
1306    reference("chi-sep.echo-combination", &[sep(CHI_SEP_TOOLBOX), MULTI_ECHO],
1307        "field_mapping.b0_estimation", WEIGHTED_AVG,
1308        "The χ-separation toolbox combines the unwrapped echoes by a weighted average",
1309        "χ-separation toolbox Chisep_script.m:113-123; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1310    reference("chi-sep.echo-weights", &[sep(CHI_SEP_TOOLBOX), MULTI_ECHO],
1311        "field_mapping.b0_estimation.weighting", &[Choice("assumed-decay")],
1312        "The toolbox weights the echoes by TE·exp(−TE/T2*) with T2* = 40 ms (AssumedDecay, 0.04 s)",
1313        "χ-separation toolbox Chisep_script.m:113-123"),
1314    reference("qsmnetme.echo-combination", &[inv(QSMNETME), MULTI_ECHO],
1315        "field_mapping.b0_estimation", WEIGHTED_AVG,
1316        "QSMnetME's toolbox combines the unwrapped echoes by a weighted average",
1317        "χ-separation toolbox Chisep_script.m:113-123; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1318    reference("qsmnetme.echo-weights", &[inv(QSMNETME), MULTI_ECHO],
1319        "field_mapping.b0_estimation.weighting", &[Choice("assumed-decay")],
1320        "QSMnetME's toolbox weights the echoes by TE·exp(−TE/T2*) with T2* = 40 ms (AssumedDecay, 0.04 s)",
1321        "χ-separation toolbox Chisep_script.m:113-123"),
1322    // ---------------------------------------------------------------- Background field removal
1323    reference("hdqsm.bg-pdf", &[inv(&[Choice("hdqsm")])], "bg_removal.algorithm", PDF,
1324        "HD-QSM's in-vivo pipeline removes the background with PDF",
1325        "Lambert et al. 2022 MRM 88:1600 (HD-QSM), §3.3"),
1326    reference("amp-pe.bg-pdf", &[inv(&[Choice("amp-pe")])], "bg_removal.algorithm", PDF,
1327        "AMP-PE's pipeline removes the background with PDF",
1328        "QSM_AMP_PE README (Overview, step 4); qsm_multi_echo_combined.m L112-139"),
1329    reference("lsqr.bg-sharp", &[inv(&[Choice("lsqr")])], "bg_removal.algorithm",
1330        &[Choice("sharp")],
1331        "The authors' LSQR/HEIDI pipeline inverts a SHARP local field",
1332        "qsm_heidi makefile L14-20, def.sh (4592631)"),
1333    reference("rts.bg-vsharp", &[inv(&[Choice("rts")])], "bg_removal.algorithm", VSHARP,
1334        "RTS's author's toolbox (QSM.jl) runs V-SHARP before it",
1335        "QSM.jl README.md:49 (fde4b92)"),
1336    reference("xqsm.bg-resharp", &[inv(&[Choice("xqsm")])], "bg_removal.algorithm", &[Choice("resharp")],
1337        "xQSM's in-vivo pipeline removes the background with RESHARP",
1338        "Gao et al. 2021 NMR Biomed 34:e4461 (xQSM), in-vivo experiments"),
1339    reference_stated("qsmnet.bg-vsharp", &[inv(&[Choice("qsmnet"), Choice("qsmnet-plus")])], "bg_removal.algorithm",
1340        VSHARP, "QSMnet's README instructs 3D V-SHARP (STI Suite) background removal",
1341        "SNU-LIST/QSMnet README.md:64-68 (Process flow), :75"),
1342    reference("qsmgan.bg-vsharp", &[inv(&[Choice("qsmgan")])], "bg_removal.algorithm", VSHARP,
1343        "QSMGAN was trained on V-SHARP local fields",
1344        "Chen et al. 2020 NeuroImage 207:116389 (QSMGAN), Methods; checkpoint config input_algo"),
1345    reference("lpcnn.bg-vsharp", &[inv(&[Choice("lpcnn")])], "bg_removal.algorithm", VSHARP,
1346        "LPCNN's local fields were from V-SHARP",
1347        "Lai et al. MICCAI 2020 (LPCNN), Dataset Acquisition and Preprocessing"),
1348    reference("chi-sep.bg-vsharp", &[sep(CHI_SEP_TOOLBOX)], "bg_removal.algorithm", VSHARP,
1349        "The χ-separation toolbox and χ-sepnet remove the background with V-SHARP",
1350        "χ-separation toolbox Chisep_script.m:136-139; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1351    reference("qsmnetme.bg-vsharp", &[inv(QSMNETME)], "bg_removal.algorithm", VSHARP,
1352        "QSMnetME's toolbox removes the background with V-SHARP",
1353        "χ-separation toolbox Chisep_script.m:136-139; Kim et al. 2025 (χ-sepnet) §2.1.1"),
1354    reference("r2star-qsm.bg-pdf", &[sep(&[Choice("r2star-qsm")])], "bg_removal.algorithm", PDF,
1355        "R2*-QSM's local field was from PDF",
1356        "Dimov et al. 2022 Tomography 8:1544, Methods"),
1357    reference("decompose.bg-vsharp", &[sep(&[Choice("decompose")])], "bg_removal.algorithm", VSHARP,
1358        "DECOMPOSE's pipeline removes the background with V-SHARP",
1359        "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1360    reference("sti.bg-vsharp", &[inv(STI)], "bg_removal.algorithm", VSHARP,
1361        "STI Suite's QSM_GUI feeds iLSQR and STAR-QSM the V-SHARP local field",
1362        "STI Suite 3.0 QSM_GUI, probed headless (recorded in QSM.rs star.rs and ilsqr.rs docs)"),
1363    reference("sti.vsharp-no-deconvolve", &[inv(STI), bg(VSHARP)], "bg_removal.vsharp.deconvolve",
1364        &[Bool(false)],
1365        "STI Suite's V_SHARP does not deconvolve",
1366        "STI Suite 3.0 V_SHARP, probed (recorded in QSM.rs vsharp.rs docs)"),
1367    Rule {
1368        id: "sti.vsharp-radius",
1369        when: &[inv(STI), bg(VSHARP)],
1370        setting: "bg_removal.vsharp.max_radius",
1371        expect: Test::OneOf { values: &[Number(12.0)] },
1372        default: Number(12.0),
1373        unit: Unit::Mm,
1374        strength: Strength::Reference,
1375        basis: Basis::ReferenceChoice,
1376        reason: "STI Suite's QSM_GUI runs V_SHARP with smvsize 12 mm (also V_SHARP's default)",
1377        source: "STI Suite 3.0 QSM_GUI and V_SHARP, probed (recorded in QSM.rs vsharp.rs docs)",
1378    },
1379    reference("sti.vsharp-grid", &[inv(STI), bg(VSHARP)], "bg_removal.vsharp.grid",
1380        &[Choice("isotropic-1mm")],
1381        "STI Suite's V_SHARP estimates the background on a 1 mm grid",
1382        "STI Suite 3.0 V_SHARP (recorded in QSM.rs vsharp.rs docs)"),
1383    Rule {
1384        id: "sti.vsharp-min-radius",
1385        when: &[inv(STI), bg(VSHARP)],
1386        setting: "bg_removal.vsharp.min_radius",
1387        expect: Test::OneOf { values: &[Number(crate::bgremove::vsharp::STI_MIN_RADIUS_MM)] },
1388        default: Number(crate::bgremove::vsharp::STI_MIN_RADIUS_MM),
1389        unit: Unit::Mm,
1390        strength: Strength::Reference,
1391        basis: Basis::ReferenceChoice,
1392        reason: "STI Suite's V_SHARP drops the voxels only its 1 mm ball fits, so its radii in \
1393                 effect run down to 2 mm",
1394        source: "STI Suite 3.0 V_SHARP, probed (QSM.rs VsharpParams, #293)",
1395    },
1396    Rule {
1397        id: "sti.vsharp-radius-step",
1398        when: &[inv(STI), bg(VSHARP)],
1399        setting: "bg_removal.vsharp.radius_step",
1400        expect: Test::OneOf { values: &[Number(crate::bgremove::vsharp::STI_RADIUS_STEP_MM)] },
1401        default: Number(crate::bgremove::vsharp::STI_RADIUS_STEP_MM),
1402        unit: Unit::Mm,
1403        strength: Strength::Reference,
1404        basis: Basis::ReferenceChoice,
1405        reason: "STI Suite's V_SHARP steps its kernel radius by 1 mm",
1406        source: "STI Suite 3.0 V_SHARP, probed (QSM.rs VsharpParams, #293)",
1407    },
1408    Rule {
1409        id: "sti.ilsqr-iterations",
1410        when: &[inv(&[Choice("ilsqr")])],
1411        setting: "inversion.ilsqr.max_iter",
1412        expect: Test::OneOf { values: &[Number(crate::inversion::STI_DEFAULT_NITER as f64)] },
1413        default: Number(crate::inversion::STI_DEFAULT_NITER as f64),
1414        unit: Unit::None,
1415        strength: Strength::Reference,
1416        basis: Basis::ReferenceChoice,
1417        reason: "QSM_iLSQR runs 100 iterations when called without niter, as STI Suite's STI_Templates.m calls it",
1418        source: "STI Suite 3.0 QSM_iLSQR default, probed (bitwise equal to niter 100); STI_Templates.m l. 115; UK Biobank pipeline (QSM.rs IlsqrParams::max_iter)",
1419    },
1420    Rule {
1421        id: "sti.ilsqr-pad",
1422        when: &[inv(&[Choice("ilsqr")])],
1423        setting: "inversion.ilsqr.pad_mm",
1424        expect: Test::OneOf { values: &[Number(crate::inversion::STI_GUI_PAD_MM)] },
1425        default: Number(crate::inversion::STI_GUI_PAD_MM),
1426        unit: Unit::Mm,
1427        strength: Strength::Reference,
1428        basis: Basis::ReferenceChoice,
1429        reason: "STI Suite's QSM_GUI calls QSM_iLSQR with padsize [12 12 12]",
1430        source: "STI Suite 3.0 QSM_GUI, probed headless (QSM.rs IlsqrParams::pad_mm, #293)",
1431    },
1432    reference("resharp.radius", &[bg(&[Choice("resharp")])], "bg_removal.resharp.radius",
1433        &[Number(3.0)],
1434        "RESHARP's author's code defaults to a 3 mm kernel",
1435        "sunhongfu/QSM background_field_removal/resharp.m l. 24-26"),
1436    Rule {
1437        id: "resharp.iterations",
1438        when: &[bg(&[Choice("resharp")])],
1439        setting: "bg_removal.resharp.max_iter",
1440        expect: Test::Range { min: Some(200.0), max: None },
1441        default: Number(200.0),
1442        unit: Unit::None,
1443        strength: Strength::Reference,
1444        basis: Basis::ReferenceChoice,
1445        reason: "RESHARP's author's code allows its cgs solve up to 200 iterations",
1446        source: "sunhongfu/QSM background_field_removal/resharp.m l. 32-34, 92",
1447    },
1448    Rule {
1449        id: "pdf.cg-iterations",
1450        when: &[bg(PDF)],
1451        setting: "bg_removal.pdf.n_cg",
1452        expect: Test::Range { min: Some(PDF_SUGGESTED_N_CG), max: None },
1453        default: Number(PDF_SUGGESTED_N_CG),
1454        unit: Unit::None,
1455        strength: Strength::Recommended,
1456        basis: Basis::Measured,
1457        reason: "A tuning value: PDF.m's 30 CG iterations can stop far from convergence. At 300, \
1458                 a Philips 3 T scan's local field correlated 0.83 with V-SHARP's, against 0.54 \
1459                 at 30 (a GE scan: 0.84 against 0.85), and on QSM-CI's simulated head NRMSE was \
1460                 44 %, against about 100 % for MEDI's PDF.m at 30; neither scan converged \
1461                 within 300. Costs about 30 s more per 1 mm whole-brain scan",
1462        source: "QSM.rs PdfParams::n_cg docs (travelling-head scans); QSMxT/QSM-CI#322 (pdf-cornell \
1463                 vs pdf-qsmrs on the simulated head); PDF.m default n_CG 30, MEDI toolbox \
1464                 2024.11.26",
1465    },
1466    // A settings file from before the multigrid port can hold the SOR solver's 1e-6, a relative
1467    // tolerance; the setting is kept as given and this suggests LBV.m's own (QSM.rs#406).
1468    reference("lbv.tolerance", &[bg(LBV)], "bg_removal.lbv.tol", &[Number(0.01)],
1469        "LBV.m stops its coarsest grid's relaxation when the summed absolute update falls to 0.01 \
1470         (radians over the echo spacing)",
1471        "MEDI toolbox 2024.11.26 functions/_LBV/LBV.m:29-31; QSM.rs LbvParams::tol"),
1472    reference("ismv-qsmjl.tolerance", &[method("bg_removal.ismv.variant", &[Choice("qsmjl")])],
1473        "bg_removal.ismv.tol", &[Number(1e-3)],
1474        "QSM.jl's ismv stops when the residual falls to 1e-3 of the first",
1475        "QSM.jl src/bgremove/ismv.jl:39 (fde4b92); QSM.rs IsmvParams::qsmjl"),
1476    reference("ismv-qsmjl.max-iterations", &[method("bg_removal.ismv.variant", &[Choice("qsmjl")])],
1477        "bg_removal.ismv.max_iter", &[Number(500.0)],
1478        "QSM.jl's ismv stops after at most 500 iterations",
1479        "QSM.jl src/bgremove/ismv.jl:40 (fde4b92); QSM.rs IsmvParams::qsmjl"),
1480    // ---------------------------------------------------------------- Dipole inversion
1481    Rule {
1482        id: "star.padding",
1483        when: &[inv(&[Choice("star")])],
1484        setting: "inversion.star.pad",
1485        expect: Test::OneOf { values: &[Value::Vector(&[8.0, 8.0, 20.0])] },
1486        default: Value::Vector(&[8.0, 8.0, 20.0]),
1487        unit: Unit::Voxels,
1488        strength: Strength::Reference,
1489        basis: Basis::ReferenceChoice,
1490        reason: "STI Suite 3.0's QSM_GUI passes padsize [8 8 20] to QSM_star on every grid",
1491        source: "STI Suite 3.0 QSM_GUI (probed headless, recorded in QSM.rs StarParams::pad, #283)",
1492    },
1493    reference("qsmart.inner-inversion", &[inv(&[Choice("qsmart")])], "inversion.qsmart.inversion",
1494        &[Choice("ilsqr")],
1495        "QSMART's two stages invert with STI Suite's iLSQR",
1496        "Yaghmaie et al. 2021 §3.4; QSMART QSMART.m l. 32-39"),
1497    Rule {
1498        id: "qsmart.ilsqr-iterations",
1499        when: &[inv(&[Choice("qsmart")])],
1500        setting: "inversion.qsmart.ilsqr_max_iter",
1501        expect: Test::OneOf { values: &[Number(50.0)] },
1502        default: Number(50.0),
1503        unit: Unit::None,
1504        strength: Strength::Reference,
1505        basis: Basis::ReferenceChoice,
1506        reason: "QSMART calls QSM_iLSQR with 'niter', 50",
1507        source: "QSMART QSMART.m l. 32-33; Yaghmaie et al. 2021 §3.4",
1508    },
1509    Rule {
1510        id: "qsmart.ilsqr-pad",
1511        when: &[inv(&[Choice("qsmart")])],
1512        setting: "inversion.qsmart.ilsqr_pad_mm",
1513        expect: Test::OneOf { values: &[Number(crate::inversion::STI_DEFAULT_PAD_MM)] },
1514        default: Number(crate::inversion::STI_DEFAULT_PAD_MM),
1515        unit: Unit::Mm,
1516        strength: Strength::Reference,
1517        basis: Basis::ReferenceChoice,
1518        reason: "QSMART calls QSM_iLSQR without 'padsize', so STI Suite's default padding applies",
1519        source: "QSMART QSMART.m l. 32; STI Suite QSM_iLSQR default, probed (QSM.rs ilsqr.rs docs)",
1520    },
1521    // ---------------------------------------------------------------- MEDI (#288)
1522    reference("medi.bg-pdf",
1523        &[inv(&[Choice("medi")]), method("inversion.medi.msmv", &[Bool(false)])],
1524        "bg_removal.algorithm", PDF,
1525        "MEDI's pipeline inverts the PDF local field (also InversionConfig::reference_bg_removal)",
1526        "MEDI toolbox 2024.11.26 README.m:124,144"),
1527    // ---------------------------------------------------------------- MEDI-mSMV (#285)
1528    Rule {
1529        id: "medi-msmv.three-echoes",
1530        when: &[inv(&[Choice("medi")]), method("inversion.medi.msmv", &[Bool(true)]),
1531                fact_at_most(Fact::EchoCount, 2.0)],
1532        setting: "inversion.medi.msmv",
1533        expect: Test::OneOf { values: &[Bool(false)] },
1534        default: Bool(false),
1535        unit: Unit::None,
1536        strength: Strength::Required,
1537        basis: Basis::Implementation,
1538        reason: "MEDI-mSMV's vessel mask needs R2* from arlo.m, which needs at least three echoes; \
1539                 the pipeline refuses fewer",
1540        source: "MEDI toolbox arlo.m:22-26; QSM.rs MediParams::msmv docs (#285)",
1541    },
1542    one_of("medi-msmv.bg-removal",
1543        &[inv(&[Choice("medi")]), method("inversion.medi.msmv", &[Bool(true)])],
1544        "bg_removal.algorithm", &[Choice("pdf"), Choice("vsharp"), Choice("lbv")],
1545        Strength::Reference, Basis::ReferenceChoice,
1546        "The mSMV authors run it on a local field from PDF, V-SHARP or LBV (their README's \
1547         example uses PDF; it lists these as examples, \"by PDF, VSHARP, LBV, etc.\", without \
1548         recommending one)",
1549        "agr78/mSMV README (in_file; MEDI example: PDF(...,0,100)); Roberts et al. 2024 MRM, Methods"),
1550    discourage("medi-msmv.prefilter",
1551        &[inv(&[Choice("medi")]), method("inversion.medi.msmv", &[Bool(true)])],
1552        "inversion.medi.msmv.prefilter", &[Bool(false)], Bool(true), Basis::Measured,
1553        "With the prefilter off, MEDI fits the unfiltered field with the SMV-filtered kernel: \
1554         NRMSE 280-740 % on the phantom after PDF, V-SHARP or LBV, and 481 % in the authors' MATLAB",
1555        "QSM.rs inversion::msmv docs and MsmvParams::prefilter (#285)"),
1556    // ---------------------------------------------------------------- Echo time of the ppm↔rad conversion (#276)
1557    // With one echo every choice is the same time, so these need several.
1558    measured_better("fansi.te-spacing", &[inv(&[Choice("fansi"), Choice("fansi-tgv")]), MULTI_ECHO],
1559        "inversion.fansi.te", &[Choice("spacing")],
1560        "FANSI's multi-echo script converts at a TE difference; at the echo spacing nlTV and nlTGV \
1561         did better than at the first echo on every metric on the phantom",
1562        "FANSI-toolbox script_DICOM_Philips.m L364, L370; QSM.rs FansiParams::te docs (#276, #418)"),
1563    measured_better("ndi.te-first", &[inv(&[Choice("ndi")]), MULTI_ECHO],
1564        "inversion.ndi.te", &[Choice("first")],
1565        "FANSI's script allows any single echo time; the first echo gave NDI its best NRMSE and \
1566         correlation on the phantom",
1567        "FANSI-toolbox script_DICOM_Philips.m L364; QSM.rs NdiParams::te docs (#276, #418)"),
1568    measured_better("l1qsm.te-first", &[inv(&[Choice("l1qsm")]), MULTI_ECHO],
1569        "inversion.l1qsm.te", &[Choice("first")],
1570        "FANSI's script allows any single echo time; the first echo did better than the spacing \
1571         on the phantom (XSIM 0.776 vs 0.764, NRMSE 38.9 vs 46.9 %)",
1572        "FANSI-toolbox script_DICOM_Philips.m L364; QSM.rs L1QsmParams::te docs (#276, #418)"),
1573    discourage("whqsm.flat-weight-te-spacing",
1574        &[inv(&[Choice("whqsm")]), method("inversion.whqsm.weight", &[Choice("flat")]), MULTI_ECHO],
1575        "inversion.whqsm.te", &[Choice("spacing")], Choice("first"), Basis::Measured,
1576        "With the flat weight, WH-QSM did worse at the echo spacing than at the first echo on the \
1577         simulated head (NRMSE 42.0 vs 33.6 %, r 0.914 vs 0.945)",
1578        "QSM.rs WhQsmParams::te docs (#276)"),
1579    measured_better("whqsm.magnitude-weight-te-spacing",
1580        &[inv(&[Choice("whqsm")]),
1581          method("inversion.whqsm.weight", &[Choice("max-normalised"), Choice("in-vivo")]), MULTI_ECHO],
1582        "inversion.whqsm.te", &[Choice("spacing")],
1583        "With a magnitude weight, the echo spacing did better than the first echo on the phantom \
1584         (XSIM 0.676 vs 0.638); FANSI's script converts at a TE difference with a magnitude-derived weight",
1585        "QSM.rs WhQsmParams::te docs (#276, #418); FANSI-toolbox script_DICOM_Philips.m L364, L436"),
1586    reference("star.te-sum", &[inv(&[Choice("star")]), MULTI_ECHO], "inversion.star.te", &[Choice("sum")],
1587        "STI Suite's QSM_GUI gives QSM_star the summed phase with TE = ΣTE",
1588        "STI Suite 3.0 QSM_GUI, probed (recorded in QSM.rs StarParams::te, #276)"),
1589    reference("medi.te-spacing", &[inv(&[Choice("medi")]), MULTI_ECHO], "inversion.medi.te", &[Choice("spacing")],
1590        "MEDI_L1 converts with delta_TE = TE(2) − TE(1)",
1591        "MEDI toolbox 2024.11.26 MEDI_L1.m:202; Read_DICOM.m:166-168"),
1592    reference("tfi.te-spacing", &[inv(&[Choice("tfi")]), MULTI_ECHO], "inversion.tfi.te", &[Choice("spacing")],
1593        "TFI runs through MEDI_L1, which converts with delta_TE",
1594        "MEDI toolbox 2024.11.26 MEDI_L1.m:202 (QSM.rs TfiParams::te, #276)"),
1595    reference("heidi.te-last", &[HEIDI_ON, MULTI_ECHO], "inversion.heidi.te", &[Choice("last")],
1596        "The authors' HEIDI pipeline converts at the last echo time",
1597        "qsm_heidi phase2susceptibilityfactor.m L70-71 (QSM.rs HeidiParams::te, #276)"),
1598    // ---------------------------------------------------------------- HEIDI after the inversion
1599    Rule {
1600        id: "heidi.local-field",
1601        when: &[HEIDI_ON],
1602        setting: "inversion.algorithm",
1603        expect: Test::NoneOf { values: HEIDI_NO_LOCAL_FIELD },
1604        default: Choice("lsqr"),
1605        unit: Unit::None,
1606        strength: Strength::Required,
1607        basis: Basis::Implementation,
1608        reason: "HEIDI's homogeneity weights and cone fill take the local field the seed was \
1609                 inverted from; this method has none",
1610        source: "QSM.rs heidi.rs docs (Any seed); run_heidi_cone_fill",
1611    },
1612    // The seed rules name the seeds they are about in `expect` (none of them), not in `when`, so
1613    // each fires on its own seeds only and is resolved by the seed it names. Every seed but LSQR
1614    // is offered LSQR: recommended for the unregularised seeds and RTS, discouraged (with the
1615    // reason) for the regularised ones, required where there is no local field.
1616    Rule {
1617        id: "heidi.seed-lsqr",
1618        when: &[HEIDI_ON],
1619        setting: "inversion.algorithm",
1620        expect: Test::NoneOf { values: HEIDI_UNREGULARISED_SEEDS },
1621        default: Choice("lsqr"),
1622        unit: Unit::None,
1623        strength: Strength::Recommended,
1624        basis: Basis::Stated,
1625        reason: "HEIDI as published keeps the well-conditioned k-space of a minimally regularised \
1626                 LSQR solution and fills only the cone",
1627        source: "Schweser et al. 2012 NeuroImage 62:2083 (HEIDI); qsm_heidi makefile, inversion_lsqr.m then inversion_heidi.m (4592631)",
1628    },
1629    Rule {
1630        id: "heidi.seed-rts",
1631        when: &[HEIDI_ON],
1632        setting: "inversion.algorithm",
1633        expect: Test::NoneOf { values: &[Choice("rts")] },
1634        default: Choice("lsqr"),
1635        unit: Unit::None,
1636        strength: Strength::Recommended,
1637        basis: Basis::Stated,
1638        reason: "HEIDI's reference seed is LSQR; RTS already splits k-space at the cone and fills it \
1639                 with TV of its own",
1640        source: "Schweser et al. 2012 NeuroImage 62:2083 (HEIDI); Kames et al. 2018 NeuroImage 167:276 (RTS)",
1641    },
1642    discourage("heidi.seed-regularised", &[HEIDI_ON], "inversion.algorithm",
1643        HEIDI_REGULARISED_SEEDS, Choice("lsqr"), Basis::Stated,
1644        "HEIDI replaces only the dipole cone, so this method's regularisation outside the cone is \
1645         kept; HEIDI takes that region from a minimally regularised, near data-consistent solution",
1646        "Schweser et al. 2012 NeuroImage 62:2083 (HEIDI); QSM.rs heidi.rs docs (Any seed)"),
1647    heidi_truncation("heidi.tkd-cone", &[HEIDI_ON, inv(&[Choice("tkd")]), HEIDI_DEFAULT_CONE],
1648        "inversion.tkd.threshold"),
1649    heidi_truncation("heidi.tsvd-cone", &[HEIDI_ON, inv(&[Choice("tsvd")]), HEIDI_DEFAULT_CONE],
1650        "inversion.tsvd.threshold"),
1651    // ---------------------------------------------------------------- χ-separation inputs
1652    reference("chi-sep-ilsqr.qsm-ilsqr", &[sep(&[Choice("chi-sep-ilsqr")])], "inversion.algorithm",
1653        &[Choice("ilsqr")],
1654        "The χ-separation toolbox feeds it STI Suite iLSQR QSM",
1655        "χ-separation toolbox v1.1.3 Chisep_script.m:145,170"),
1656    reference("chi-sep-medi.qsm-ilsqr", &[sep(&[Choice("chi-sep-medi")])], "inversion.algorithm",
1657        &[Choice("ilsqr")],
1658        "The χ-separation toolbox initialises χ-separation-MEDI from STI Suite iLSQR QSM",
1659        "χ-separation toolbox v1.1.3 Chisep_script.m:145,158"),
1660    reference("r2star-qsm.qsm-medi", &[sep(&[Choice("r2star-qsm")])], "inversion.algorithm",
1661        &[Choice("medi")],
1662        "R2*-QSM's χ was reconstructed with MEDI",
1663        "Dimov et al. 2022 Tomography 8:1544, Methods"),
1664    reference("decompose.qsm-star", &[sep(&[Choice("decompose")])], "inversion.algorithm",
1665        &[Choice("star")],
1666        "DECOMPOSE's χ of each echo was reconstructed with STAR-QSM",
1667        "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1668    recommend("decompose.qsm-per-echo", &[sep(&[Choice("decompose")])], "qsm_per_echo", &[Bool(true)],
1669        "DECOMPOSE's authors reconstruct a QSM for each echo, χ(t), rather than one QSM from all \
1670         echoes combined, and state that a TE-dependent QSM input is necessary",
1671        "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §2.2, §4.1; STAR-QSM per echo §3.6"),
1672    measured_better("decompose.unwrap-7t",
1673        &[sep(&[Choice("decompose")]), fact_at_least(Fact::FieldStrength, DECOMPOSE_UNWRAP_FROM_T)],
1674        "separation.decompose.unwrap_phase", &[Bool(true)],
1675        "DECOMPOSE was designed for 3 T (its χ bound keeps the phase under 2π at TE 25 ms and \
1676         3 T); at 7 T the phase wraps between echoes and χ+ partly collapses unless the phase is \
1677         unwrapped along the echoes (with decompose.chi-bound-7t's bound)",
1678        "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §2.2; measured on the QSM-CI 7 T \
1679         chisep phantom, χ+ r 0.48 off, 0.66 on, 0.90 on with the echo-spacing χ bound (QSM.rs \
1680         separation::decompose docs, At 7 T)"),
1681    measured_better("decompose.chi-bound-7t",
1682        &[sep(&[Choice("decompose")]), fact_at_least(Fact::FieldStrength, DECOMPOSE_UNWRAP_FROM_T)],
1683        "separation.decompose.chi_bound", &[Choice("echo-spacing")],
1684        "A tuning value: bound the χ search at π / ((2/3)·γ·B0·ΔTE_max), computed from the scan \
1685         (0.31 ppm at 7 T with 8 ms spacing), the largest |χ| whose phase step between echoes \
1686         stays under π so that unwrapping along the echoes is unambiguous; the authors' 0.5 ppm \
1687         was set for TE 25 ms at 3 T",
1688        "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §2.2; measured on the QSM-CI 7 T \
1689         chisep phantom, χ− r 0.75 → 0.85 with this bound alone, χ+ r 0.66 → 0.90 with \
1690         unwrapping (QSM.rs separation::decompose docs, At 7 T)"),
1691    reference("decompose.reference-mean", &[sep(&[Choice("decompose")])], "reference",
1692        &[Choice("mean")],
1693        "DECOMPOSE's χ is referenced to the whole-brain mean",
1694        "Chen et al. 2021 NeuroImage 242:118477 (DECOMPOSE), §3.6"),
1695    reference("susep-net.qsm-iqsm-plus", &[sep(&[Choice("susep-net")])], "inversion.algorithm",
1696        &[Choice("iqsm-plus")],
1697        "SUSEP-Net's in-vivo QSM input is from iQSM+",
1698        "SUSEP-Net (arXiv:2506.13293) §3.2"),
1699    discourage("chi-sepnet.min-resolution",
1700        &[sep(&[Choice("chi-sepnet")]), fact_at_most(Fact::MinVoxelSize, 0.6)],
1701        "separation.algorithm", &[Choice("chi-sepnet")], Choice("chi-sep-ilsqr"), Basis::Stated,
1702        "χ-sepnet's authors state the networks process resolutions coarser than 0.6 mm",
1703        "SNU-LIST/chi-separation README; Kim et al. 2024 (arXiv:2410.12239) §4"),
1704    // The toolbox's Tukey window, before all three of its χ-separation methods (Chisep_script.m
1705    // l. 69-73 run ahead of the χ-sepnet, χ-sep-MEDI and χ-sep-iLSQR calls). The script's comment
1706    // calls the vendor values a "Recommendation" but gives no reason: the authors' setting.
1707    reference_stated("chi-sep.tukey-siemens-ge",
1708        &[sep(CHI_SEP_TOOLBOX_ITERATIVE), manufacturer(&[Choice("siemens"), Choice("ge")])],
1709        "tukey_window", &[Number(CHISEP_TUKEY_SIEMENS_GE)], TUKEY_SIEMENS_GE_REASON, TUKEY_SOURCE),
1710    reference_stated("chi-sep.tukey-philips",
1711        &[sep(CHI_SEP_TOOLBOX_ITERATIVE), manufacturer(&[Choice("philips")])],
1712        "tukey_window", &[Number(0.0)], TUKEY_PHILIPS_REASON, TUKEY_SOURCE),
1713    reference_stated("chi-sepnet.tukey-siemens-ge",
1714        &[sep(&[Choice("chi-sepnet")]), CHI_SEPNET_TOOLBOX_ROUTE, manufacturer(&[Choice("siemens"), Choice("ge")])],
1715        "tukey_window", &[Number(CHISEP_TUKEY_SIEMENS_GE)], TUKEY_SIEMENS_GE_REASON, TUKEY_SOURCE),
1716    reference_stated("chi-sepnet.tukey-philips",
1717        &[sep(&[Choice("chi-sepnet")]), CHI_SEPNET_TOOLBOX_ROUTE, manufacturer(&[Choice("philips")])],
1718        "tukey_window", &[Number(0.0)], TUKEY_PHILIPS_REASON, TUKEY_SOURCE),
1719];
1720
1721#[cfg(test)]
1722mod tests {
1723    use super::*;
1724    use crate::pipeline::config::*;
1725
1726    fn facts() -> ScanFacts {
1727        ScanFacts {
1728            field_strength: Some(3.0),
1729            echo_count: Some(4),
1730            voxel_size: Some((1.0, 1.0, 1.0)),
1731            dims: Some((192, 256, 160)),
1732            manufacturer: Some(Manufacturer::Siemens),
1733            target: Target::Native,
1734        }
1735    }
1736
1737    /// A configuration under test: the pipeline, the optional stages, and the scan.
1738    #[derive(Clone)]
1739    struct Case {
1740        cfg: QsmPipelineConfig,
1741        sep: Option<SeparationConfig>,
1742        seg: Option<SynthSegParams>,
1743        facts: ScanFacts,
1744    }
1745
1746    impl Case {
1747        fn new(f: impl FnOnce(&mut Case)) -> Case {
1748            let mut c = Case { cfg: QsmPipelineConfig::default(), sep: None, seg: None, facts: facts() };
1749            f(&mut c);
1750            c
1751        }
1752        fn input(&self) -> RuleInput<'_> {
1753            RuleInput { pipeline: &self.cfg, separation: self.sep.as_ref(), segmentation: self.seg.as_ref() }
1754        }
1755        fn ids(&self) -> Vec<&'static str> {
1756            evaluate(&self.input(), &self.facts).iter().map(|f| f.rule.id).collect()
1757        }
1758    }
1759
1760    fn inversion(a: InversionAlgorithm) -> impl FnOnce(&mut Case) {
1761        move |c| c.cfg.inversion.algorithm = a
1762    }
1763    fn separation(a: SeparationAlgorithm) -> impl FnOnce(&mut Case) {
1764        move |c| c.sep = Some(SeparationConfig { algorithm: a, ..Default::default() })
1765    }
1766    fn background(a: BgRemovalAlgorithm) -> impl FnOnce(&mut Case) {
1767        move |c| c.cfg.bg_removal.algorithm = a
1768    }
1769    fn linear_fit(c: &mut Case) {
1770        c.cfg.field_mapping.b0_estimation = B0EstimationMethod::LinearFit(Default::default());
1771    }
1772
1773    /// With HEIDI on, every inversion but LSQR gets a finding that offers LSQR: recommended,
1774    /// discouraged (with its reason) or required, never only information.
1775    #[test]
1776    fn heidi_offers_lsqr_for_every_other_seed() {
1777        for &a in InversionAlgorithm::VARIANTS {
1778            let mut case = Case::new(|c| { inversion(a)(c); c.cfg.inversion.apply_heidi = true });
1779            // TKD/TSVD at the cone threshold, so their truncation rule does not fire too.
1780            case.cfg.inversion.tkd.threshold = 0.1;
1781            case.cfg.inversion.tsvd.threshold = 0.1;
1782            let lsqr = evaluate(&case.input(), &case.facts).into_iter().filter(|f| {
1783                f.rule.setting == "inversion.algorithm" && f.rule.id.starts_with("heidi.")
1784                    && f.rule.default == Choice("lsqr") && f.rule.strength > Strength::Reference
1785            }).count();
1786            assert_eq!(lsqr > 0, a != InversionAlgorithm::Lsqr, "{a:?}: {:?}", case.ids());
1787        }
1788    }
1789
1790    /// A configuration that violates each rule, keyed by rule id.
1791    fn violations() -> Vec<(&'static str, Case)> {
1792        use BgRemovalAlgorithm as B;
1793        use InversionAlgorithm as I;
1794        use SeparationAlgorithm as S;
1795        vec![
1796            ("medi.mask-bet", Case::new(inversion(I::Medi))),
1797            ("tfi.mask-bet", Case::new(inversion(I::Tfi))),
1798            ("pdf.mask-bet", Case::new(background(B::Pdf))),
1799            ("amp-pe.mask-bet", Case::new(inversion(I::AmpPe))),
1800            ("qsmnet.mask-bet", Case::new(inversion(I::QsmnetPlus))),
1801            ("qsmgan.mask-bet", Case::new(inversion(I::Qsmgan))),
1802            ("lpcnn.mask-bet", Case::new(inversion(I::Lpcnn))),
1803            ("nextqsm.mask-bet", Case::new(inversion(I::Nextqsm))),
1804            ("iqsm.mask-bet", Case::new(inversion(I::Iqsm))),
1805            ("bfrnet.mask-bet", Case::new(background(B::Bfrnet))),
1806            ("chi-sep.mask-bet", Case::new(separation(S::ChiSepMedi))),
1807            ("qsmnetme.mask-bet", Case::new(inversion(I::Qsmnetme))),
1808            ("decompose.mask-bet", Case::new(separation(S::Decompose))),
1809            ("mindgrab.wasm-heap", Case::new(|c| {
1810                c.cfg.masking.sections[0].generator = MaskOp::MindGrab(Default::default());
1811                c.facts.target = Target::Wasm;
1812            })),
1813            ("hd-bet.wasm-heap", Case::new(|c| {
1814                c.cfg.masking.sections[0].generator = MaskOp::HdBet(crate::bet::HdBetParams::default());
1815                c.facts.target = Target::Wasm;
1816            })),
1817            ("synthseg.wasm-windows", Case::new(|c| {
1818                c.seg = Some(SynthSegParams::default());
1819                c.facts.target = Target::Wasm;
1820            })),
1821            ("synthseg.wasm-128-window", Case::new(|c| {
1822                c.seg = Some(SynthSegParams {
1823                    inference: SynthSegInference::SlidingWindow { patch: 128, overlap: 48 },
1824                    ..SynthSegParams::low_memory()
1825                });
1826                c.facts.target = Target::Wasm;
1827            })),
1828            ("fansi.no-laplacian-multi-echo", Case::new(|c| {
1829                c.cfg.inversion.algorithm = I::Ndi;
1830                c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1831            })),
1832            ("qsmnet.unwrap-laplacian", Case::new(inversion(I::Qsmnet))),
1833            ("autoqsm.unwrap-laplacian", Case::new(inversion(I::Autoqsm))),
1834            ("qsmgan.unwrap-laplacian", Case::new(inversion(I::Qsmgan))),
1835            ("decompose.unwrap-laplacian", Case::new(separation(S::Decompose))),
1836            ("chi-sep.unwrap-romeo", Case::new(|c| {
1837                separation(S::ChiSepIlsqr)(c);
1838                c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1839            })),
1840            ("qsmnetme.unwrap-romeo", Case::new(|c| {
1841                inversion(I::Qsmnetme)(c);
1842                c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1843            })),
1844            ("field-mapping.phase-offsets", Case::new(|c| c.cfg.field_mapping.phase_offset_removal = false)),
1845            ("field-mapping.bipolar-needs-three-echoes", Case::new(|c| {
1846                c.cfg.field_mapping.bipolar_correction = true;
1847                c.facts.echo_count = Some(2);
1848            })),
1849            ("field-mapping.bipolar-not-with-laplacian", Case::new(|c| {
1850                c.cfg.field_mapping.bipolar_correction = true;
1851                c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1852            })),
1853            ("medi.field-complex-fit", Case::new(inversion(I::Medi))),
1854            ("pdf.field-complex-fit", Case::new(background(B::Pdf))),
1855            ("r2star-qsm.field-complex-fit", Case::new(separation(S::R2starQsm))),
1856            ("amp-pe.echo-combination", Case::new(|c| { inversion(I::AmpPe)(c); linear_fit(c) })),
1857            ("amp-pe.echo-weights", Case::new(inversion(I::AmpPe))),
1858            ("qsmnet.echo-combination", Case::new(|c| { inversion(I::Qsmnet)(c); linear_fit(c) })),
1859            ("qsmnet.echo-weights", Case::new(inversion(I::Qsmnet))),
1860            ("autoqsm.echo-combination", Case::new(|c| { inversion(I::Autoqsm)(c); linear_fit(c) })),
1861            ("autoqsm.echo-weights", Case::new(inversion(I::Autoqsm))),
1862            ("chi-sep.echo-combination", Case::new(|c| { separation(S::ChiSepNet)(c); linear_fit(c) })),
1863            ("chi-sep.echo-weights", Case::new(separation(S::ChiSepIlsqr))),
1864            ("qsmnetme.echo-combination", Case::new(|c| { inversion(I::Qsmnetme)(c); linear_fit(c) })),
1865            ("qsmnetme.echo-weights", Case::new(inversion(I::Qsmnetme))),
1866            ("hdqsm.bg-pdf", Case::new(inversion(I::Hdqsm))),
1867            ("amp-pe.bg-pdf", Case::new(inversion(I::AmpPe))),
1868            ("lsqr.bg-sharp", Case::new(inversion(I::Lsqr))),
1869            ("heidi.local-field", Case::new(|c| { inversion(I::Tfi)(c); c.cfg.inversion.apply_heidi = true })),
1870            ("heidi.seed-lsqr", Case::new(|c| {
1871                inversion(I::Tkd)(c);
1872                c.cfg.inversion.apply_heidi = true;
1873                c.cfg.inversion.tkd.threshold = 0.1;
1874            })),
1875            ("heidi.seed-rts", Case::new(|c| { inversion(I::Rts)(c); c.cfg.inversion.apply_heidi = true })),
1876            ("heidi.seed-regularised", Case::new(|c| { inversion(I::Medi)(c); c.cfg.inversion.apply_heidi = true })),
1877            ("heidi.tkd-cone", Case::new(|c| { inversion(I::Tkd)(c); c.cfg.inversion.apply_heidi = true })),
1878            ("heidi.tsvd-cone", Case::new(|c| { inversion(I::Tsvd)(c); c.cfg.inversion.apply_heidi = true })),
1879            ("rts.bg-vsharp", Case::new(background(B::Pdf))),
1880            ("xqsm.bg-resharp", Case::new(inversion(I::Xqsm))),
1881            ("qsmnet.bg-vsharp", Case::new(|c| { inversion(I::Qsmnet)(c); background(B::Lbv)(c) })),
1882            ("qsmgan.bg-vsharp", Case::new(|c| { inversion(I::Qsmgan)(c); background(B::Lbv)(c) })),
1883            ("lpcnn.bg-vsharp", Case::new(|c| { inversion(I::Lpcnn)(c); background(B::Lbv)(c) })),
1884            ("chi-sep.bg-vsharp", Case::new(|c| { separation(S::ChiSepIlsqr)(c); background(B::Pdf)(c) })),
1885            ("qsmnetme.bg-vsharp", Case::new(|c| { inversion(I::Qsmnetme)(c); background(B::Pdf)(c) })),
1886            ("r2star-qsm.bg-pdf", Case::new(separation(S::R2starQsm))),
1887            ("decompose.bg-vsharp", Case::new(|c| { separation(S::Decompose)(c); background(B::Pdf)(c) })),
1888            ("star.padding", Case::new(|c| {
1889                c.cfg.inversion.algorithm = I::Star;
1890                c.cfg.inversion.star.pad = [12; 3];
1891            })),
1892            ("chi-sep-ilsqr.qsm-ilsqr", Case::new(separation(S::ChiSepIlsqr))),
1893            ("chi-sep-medi.qsm-ilsqr", Case::new(separation(S::ChiSepMedi))),
1894            ("medi.bg-pdf", Case::new(inversion(I::Medi))),
1895            ("sti.vsharp-min-radius", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.bg_removal.vsharp.min_radius = 1.0 })),
1896            ("sti.vsharp-radius-step", Case::new(|c| { inversion(I::Star)(c); c.cfg.bg_removal.vsharp.radius_step = 2.0 })),
1897            ("sti.ilsqr-iterations", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.inversion.ilsqr.max_iter = 40 })),
1898            ("sti.ilsqr-pad", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.inversion.ilsqr.pad_mm = 6.0 })),
1899            ("field-mapping.phase-offset-sigma", Case::new(|c| c.cfg.field_mapping.phase_offset_sigma_mm = [10.0, 10.0, 5.0])),
1900            ("medi-msmv.three-echoes", Case::new(|c| {
1901                inversion(I::Medi)(c);
1902                c.cfg.inversion.medi.msmv = Some(Default::default());
1903                c.facts.echo_count = Some(2);
1904            })),
1905            ("medi-msmv.bg-removal", Case::new(|c| {
1906                inversion(I::Medi)(c);
1907                c.cfg.inversion.medi.msmv = Some(Default::default());
1908                background(B::Sharp)(c);
1909            })),
1910            ("medi-msmv.prefilter", Case::new(|c| {
1911                inversion(I::Medi)(c);
1912                c.cfg.inversion.medi.msmv =
1913                    Some(crate::inversion::MsmvParams { prefilter: false, ..Default::default() });
1914            })),
1915            ("fansi.te-spacing", Case::new(|c| { inversion(I::FansiTgv)(c); c.cfg.inversion.fansi.te = crate::EchoTime::First })),
1916            ("ndi.te-first", Case::new(|c| { inversion(I::Ndi)(c); c.cfg.inversion.ndi.te = crate::EchoTime::Spacing })),
1917            ("l1qsm.te-first", Case::new(|c| { inversion(I::L1qsm)(c); c.cfg.inversion.l1qsm.te = crate::EchoTime::Sum })),
1918            ("whqsm.flat-weight-te-spacing", Case::new(|c| { inversion(I::Whqsm)(c); c.cfg.inversion.whqsm.te = crate::EchoTime::Spacing })),
1919            ("whqsm.magnitude-weight-te-spacing", Case::new(|c| {
1920                inversion(I::Whqsm)(c);
1921                c.cfg.inversion.whqsm.weight = crate::inversion::WhQsmWeight::InVivo;
1922            })),
1923            ("star.te-sum", Case::new(|c| { inversion(I::Star)(c); c.cfg.inversion.star.te = crate::EchoTime::First })),
1924            ("medi.te-spacing", Case::new(|c| { inversion(I::Medi)(c); c.cfg.inversion.medi.te = crate::EchoTime::First })),
1925            ("tfi.te-spacing", Case::new(|c| { inversion(I::Tfi)(c); c.cfg.inversion.tfi.te = crate::EchoTime::Last })),
1926            ("heidi.te-last", Case::new(|c| {
1927                inversion(I::Lsqr)(c);
1928                c.cfg.inversion.apply_heidi = true;
1929                c.cfg.inversion.heidi.te = crate::EchoTime::First;
1930            })),
1931            ("sti.bg-vsharp", Case::new(|c| { inversion(I::Star)(c); background(B::Pdf)(c) })),
1932            ("sti.vsharp-no-deconvolve", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.bg_removal.vsharp.deconvolve = true })),
1933            ("sti.vsharp-radius", Case::new(|c| { inversion(I::Ilsqr)(c); c.cfg.bg_removal.vsharp.max_radius = 8.0 })),
1934            ("sti.vsharp-grid", Case::new(|c| { inversion(I::Star)(c); c.cfg.bg_removal.vsharp.grid = crate::bgremove::VsharpGrid::Native })),
1935            ("resharp.radius", Case::new(|c| { background(B::Resharp)(c); c.cfg.bg_removal.resharp.radius = 6.0 })),
1936            ("resharp.iterations", Case::new(|c| { background(B::Resharp)(c); c.cfg.bg_removal.resharp.max_iter = 30 })),
1937            ("pdf.cg-iterations", Case::new(background(B::Pdf))),
1938            ("lbv.tolerance", Case::new(|c| { background(B::Lbv)(c); c.cfg.bg_removal.lbv.tol = 1e-6 })),
1939            ("ismv-qsmjl.tolerance", Case::new(|c| { background(B::Ismv)(c); c.cfg.bg_removal.ismv.variant = crate::bgremove::IsmvVariant::QsmJl })),
1940            ("ismv-qsmjl.max-iterations", Case::new(|c| { background(B::Ismv)(c); c.cfg.bg_removal.ismv.variant = crate::bgremove::IsmvVariant::QsmJl })),
1941            ("sti.unwrap-laplacian", Case::new(inversion(I::Ilsqr))),
1942            ("sti.laplacian-solver", Case::new(|c| {
1943                inversion(I::Star)(c);
1944                c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1945            })),
1946            ("qsmart.unwrap-laplacian", Case::new(inversion(I::Qsmart))),
1947            ("qsmart.laplacian-solver", Case::new(|c| {
1948                inversion(I::Qsmart)(c);
1949                c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1950            })),
1951            ("qsmart.field-echofit", Case::new(inversion(I::Qsmart))),
1952            ("qsmart.unwrap-unmasked", Case::new(|c| {
1953                inversion(I::Qsmart)(c);
1954                c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1955            })),
1956            ("sti.unwrap-unmasked", Case::new(|c| {
1957                inversion(I::Ilsqr)(c);
1958                c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::Laplacian;
1959            })),
1960            ("sti.echo-combination", Case::new(|c| { inversion(I::Star)(c); linear_fit(c) })),
1961            ("sti.echo-weights", Case::new(inversion(I::Ilsqr))),
1962            ("qsmart.mask-bet", Case::new(inversion(I::Qsmart))),
1963            ("qsmart.inner-inversion", Case::new(|c| {
1964                inversion(I::Qsmart)(c);
1965                c.cfg.inversion.qsmart.inversion = I::Star;
1966            })),
1967            ("qsmart.ilsqr-iterations", Case::new(|c| { inversion(I::Qsmart)(c); c.cfg.inversion.qsmart.ilsqr_max_iter = 100 })),
1968            ("qsmart.ilsqr-pad", Case::new(|c| { inversion(I::Qsmart)(c); c.cfg.inversion.qsmart.ilsqr_pad_mm = 12.0 })),
1969            ("r2star-qsm.qsm-medi", Case::new(separation(S::R2starQsm))),
1970            ("decompose.qsm-star", Case::new(separation(S::Decompose))),
1971            ("decompose.qsm-per-echo", Case::new(separation(S::Decompose))),
1972            ("decompose.unwrap-7t", Case::new(|c| {
1973                separation(S::Decompose)(c);
1974                c.facts.field_strength = Some(7.0);
1975            })),
1976            ("decompose.chi-bound-7t", Case::new(|c| {
1977                separation(S::Decompose)(c);
1978                c.facts.field_strength = Some(7.0);
1979            })),
1980            ("decompose.reference-mean", Case::new(|c| {
1981                separation(S::Decompose)(c);
1982                c.cfg.reference = QsmReference::None;
1983            })),
1984            ("susep-net.qsm-iqsm-plus", Case::new(separation(S::SusepNet))),
1985            ("chi-sepnet.min-resolution", Case::new(|c| {
1986                separation(S::ChiSepNet)(c);
1987                c.facts.voxel_size = Some((0.5, 0.5, 0.5));
1988            })),
1989            ("chi-sepnet.tukey-siemens-ge", Case::new(separation(S::ChiSepNet))),
1990            ("chi-sep.tukey-siemens-ge", Case::new(separation(S::ChiSepMedi))),
1991            ("chi-sep.tukey-philips", Case::new(|c| {
1992                separation(S::ChiSepIlsqr)(c);
1993                c.facts.manufacturer = Some(Manufacturer::Philips);
1994                c.cfg.tukey_window = 0.4;
1995            })),
1996            ("complex-fit.unwrap-region-growing", Case::new(|c| {
1997                c.cfg.field_mapping.b0_estimation = B0EstimationMethod::ComplexFit(Default::default());
1998            })),
1999            ("chi-sepnet.tukey-philips", Case::new(|c| {
2000                separation(S::ChiSepNet)(c);
2001                c.facts.manufacturer = Some(Manufacturer::Philips);
2002                c.cfg.tukey_window = 0.4;
2003            })),
2004        ]
2005    }
2006
2007    /// Every path a rule names is one [`current`] reads, every default passes its own test, and
2008    /// every id is unique.
2009    #[test]
2010    fn rules_are_well_formed() {
2011        let cfg = QsmPipelineConfig::default();
2012        let input = RuleInput::pipeline(&cfg);
2013        let facts = ScanFacts::default();
2014        let known = |p: &str| SETTINGS.iter().any(|(s, _)| *s == p);
2015        let mut ids = std::collections::HashSet::new();
2016        for r in RULES {
2017            assert!(ids.insert(r.id), "duplicate rule id {}", r.id);
2018            assert!(!r.when.is_empty(), "{}: no trigger", r.id);
2019            assert!(matches!(r.trigger(), Condition::Setting { .. }), "{}: the trigger is a choice", r.id);
2020            assert!(known(r.setting), "{}: {} not in SETTINGS", r.id, r.setting);
2021            for c in r.when {
2022                if let Condition::Setting { setting, .. } = c {
2023                    assert!(known(setting), "{}: {setting} not in SETTINGS", r.id);
2024                }
2025            }
2026            let d = match r.default {
2027                Value::Bool(b) => Current::Bool(b),
2028                Value::Number(x) => Current::Number(x),
2029                Value::Choice(s) => Current::Choice(s),
2030                Value::Vector(v) => Current::Vector(v.to_vec()),
2031            };
2032            assert!(r.expect.passes(&d), "{}: default does not pass its own test", r.id);
2033            assert!(!r.reason.is_empty() && !r.source.is_empty(), "{}: reason and source", r.id);
2034        }
2035        for (p, _) in SETTINGS {
2036            assert!(current(&input, &facts, p).is_some(), "{p} in SETTINGS but unreadable");
2037        }
2038    }
2039
2040    /// The default pipeline (with SynthSeg's defaults, natively) raises nothing, with or without
2041    /// scan facts. χ-separation is not part of the default pipeline: its default method's rules
2042    /// fire on the default QSM settings, because the χ-separation toolbox's pipeline differs
2043    /// from them.
2044    #[test]
2045    fn default_config_raises_nothing() {
2046        for facts in [ScanFacts::default(), facts()] {
2047            let c = Case { seg: Some(SynthSegParams::default()), facts, ..Case::new(|_| {}) };
2048            assert!(c.ids().is_empty(), "{:?}", c.ids());
2049        }
2050    }
2051
2052    /// Each rule fires on a configuration that violates it, and every rule has such a case.
2053    #[test]
2054    fn each_rule_fires_on_a_violation() {
2055        let cases = violations();
2056        for r in RULES {
2057            assert!(cases.iter().any(|(id, _)| *id == r.id), "{}: no violating case", r.id);
2058        }
2059        for (id, case) in &cases {
2060            assert!(RULES.iter().any(|r| r.id == *id), "{id}: not a rule");
2061            assert!(case.ids().contains(id), "{id} did not fire: {:?}", case.ids());
2062        }
2063    }
2064
2065    /// Each rule's kind follows its strength: only `Reference` rules are the authors' setting,
2066    /// and the JSON form says which.
2067    #[test]
2068    fn kinds_follow_strengths() {
2069        for r in RULES {
2070            assert_eq!(r.kind() == Kind::Reference, r.strength == Strength::Reference, "{}", r.id);
2071        }
2072        let json = serde_json::to_value(RULES).unwrap();
2073        for (r, j) in RULES.iter().zip(json.as_array().unwrap()) {
2074            assert_eq!(j["kind"], serde_json::to_value(r.kind()).unwrap(), "{}", r.id);
2075        }
2076        let kind = |id: &str| RULES.iter().find(|r| r.id == id).unwrap().kind();
2077        // Ashley's examples (2026-11-04): the authors' pairing vs better for a stated reason.
2078        assert_eq!(kind("complex-fit.unwrap-region-growing"), Kind::Reference);
2079        assert_eq!(kind("chi-sepnet.tukey-siemens-ge"), Kind::Reference);
2080        assert_eq!(kind("qsmnet.bg-vsharp"), Kind::Reference);
2081        assert_eq!(kind("heidi.seed-lsqr"), Kind::Reasoned);
2082        assert_eq!(kind("pdf.cg-iterations"), Kind::Reasoned);
2083    }
2084
2085    /// Precedence, and the basis matching each strength.
2086    #[test]
2087    fn strengths_order_and_rest_on_their_basis() {
2088        use Strength::*;
2089        assert!(Required > Recommended && Recommended > Discouraged && Discouraged > Reference);
2090        for r in RULES {
2091            match r.strength {
2092                Reference => assert!(matches!(r.basis, Basis::ReferenceChoice | Basis::Stated), "{}", r.id),
2093                Recommended => assert!(matches!(r.basis, Basis::Stated | Basis::Measured), "{}", r.id),
2094                Required | Discouraged => assert_ne!(r.basis, Basis::ReferenceChoice, "{}", r.id),
2095            }
2096        }
2097    }
2098
2099    /// Scan-dependent rules stay silent until the scan is known.
2100    #[test]
2101    fn unknown_facts_do_not_fire() {
2102        for (id, case) in violations() {
2103            let r = RULES.iter().find(|r| r.id == id).unwrap();
2104            let blind = Case { facts: ScanFacts { target: case.facts.target, ..Default::default() }, ..case };
2105            if r.needs_scan() {
2106                assert!(!blind.ids().contains(&id), "{id} fired without scan facts");
2107            }
2108        }
2109    }
2110
2111    #[test]
2112    fn hd_bet_low_memory_resolves_it() {
2113        let c = Case::new(|c| {
2114            c.cfg.masking.sections[0].generator = MaskOp::HdBet(crate::bet::HdBetParams::low_memory());
2115            c.facts.target = Target::Wasm;
2116        });
2117        assert!(!c.ids().contains(&"hd-bet.wasm-heap"));
2118    }
2119
2120    #[test]
2121    fn synthseg_window_scales_with_the_grid() {
2122        let c = Case::new(|c| {
2123            c.seg = Some(SynthSegParams {
2124                inference: SynthSegInference::SlidingWindow { patch: 128, overlap: 48 },
2125                ..SynthSegParams::low_memory()
2126            });
2127            c.facts.target = Target::Wasm;
2128            c.facts.dims = Some((184, 256, 128));
2129        });
2130        assert!(!c.ids().contains(&"synthseg.wasm-128-window"));
2131        let low = Case { seg: Some(SynthSegParams::low_memory()), ..c };
2132        assert!(low.ids().is_empty(), "{:?}", low.ids());
2133    }
2134
2135    /// Rules are scoped to their trigger: STAR's padding is not reported when STAR is not chosen,
2136    /// and background-removal rules are silent when the inversion skips background removal.
2137    #[test]
2138    fn rules_are_scoped() {
2139        let c = Case::new(|c| c.cfg.inversion.star.pad = [12; 3]);
2140        assert!(c.ids().is_empty(), "{:?}", c.ids());
2141        // AutoQSM takes the total field: LBV is never run, so no BFR rule fires for it.
2142        let c = Case::new(|c| {
2143            c.cfg.inversion.algorithm = InversionAlgorithm::Autoqsm;
2144            c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Lbv;
2145        });
2146        assert!(!c.ids().iter().any(|id| id.contains(".bg-")), "{:?}", c.ids());
2147        // Fixed per the defaults, the findings go away.
2148        let c = Case::new(|c| {
2149            c.cfg.inversion.algorithm = InversionAlgorithm::Hdqsm;
2150            c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Pdf;
2151            c.cfg.masking.sections[0].generator = MaskOp::Bet(Default::default());
2152            c.cfg.field_mapping.b0_estimation = B0EstimationMethod::ComplexFit(Default::default());
2153            c.cfg.field_mapping.unwrapping_algorithm = UnwrappingAlgorithm::MediRegionGrowing;
2154            c.cfg.bg_removal.pdf.n_cg = 300;
2155        });
2156        assert!(c.ids().is_empty(), "{:?}", c.ids());
2157    }
2158
2159    /// PDF's iteration count: the default is PDF.m's 30, and 300 (or more) is a measured
2160    /// suggestion, silent once followed and when PDF is not run.
2161    #[test]
2162    fn pdf_suggests_more_iterations() {
2163        let id = "pdf.cg-iterations";
2164        let r = RULES.iter().find(|r| r.id == id).unwrap();
2165        assert_eq!((r.strength, r.basis, r.default), (Strength::Recommended, Basis::Measured, Number(300.0)));
2166        assert_eq!(crate::bgremove::PdfParams::default().n_cg, 30);
2167        let with = |n: usize| Case::new(|c| {
2168            c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Pdf;
2169            c.cfg.bg_removal.pdf.n_cg = n;
2170        }).ids().contains(&id);
2171        assert!(with(30) && with(299));
2172        assert!(!with(300) && !with(1000));
2173        // Not in play without PDF, nor when the inversion skips background removal.
2174        assert!(!Case::new(|c| c.cfg.bg_removal.pdf.n_cg = 30).ids().contains(&id));
2175        let total_field = Case::new(|c| {
2176            c.cfg.inversion.algorithm = InversionAlgorithm::Autoqsm;
2177            c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Pdf;
2178        });
2179        assert!(!total_field.ids().contains(&id), "{:?}", total_field.ids());
2180    }
2181
2182    /// χ-sepnet's toolbox route, χ-sep-MEDI and χ-sep-iLSQR suggest the toolbox's Tukey window by
2183    /// manufacturer (the authors' setting, never preselected): 0.4 for Siemens and GE, 0 for
2184    /// Philips; nothing on χ-sepnet's native route, with other separation methods, for other or
2185    /// unknown manufacturers, or once followed.
2186    #[test]
2187    fn chi_sepnet_suggests_the_vendor_tukey_window() {
2188        use crate::separation::ChiSepNetRoute;
2189        let ids = |m: Option<Manufacturer>, route: ChiSepNetRoute, tukey: f64| Case::new(|c| {
2190            separation(SeparationAlgorithm::ChiSepNet)(c);
2191            c.sep.as_mut().unwrap().chi_sepnet.route = route;
2192            c.facts.manufacturer = m;
2193            c.cfg.tukey_window = tukey;
2194        }).ids().into_iter().filter(|id| id.starts_with("chi-sepnet.tukey")).collect::<Vec<_>>();
2195        let (sg, ph) = ("chi-sepnet.tukey-siemens-ge", "chi-sepnet.tukey-philips");
2196        use Manufacturer::*;
2197        use ChiSepNetRoute::*;
2198        assert_eq!(ids(Some(Siemens), Toolbox, 0.0), [sg]);
2199        assert_eq!(ids(Some(Ge), Toolbox, 0.2), [sg]);
2200        assert!(ids(Some(Ge), Toolbox, 0.4).is_empty());
2201        assert_eq!(ids(Some(Philips), Toolbox, 0.4), [ph]);
2202        assert!(ids(Some(Philips), Toolbox, 0.0).is_empty());
2203        assert!(ids(Some(Other), Toolbox, 0.0).is_empty());
2204        assert!(ids(None, Toolbox, 0.0).is_empty());
2205        assert!(ids(Some(Siemens), Native, 0.0).is_empty());
2206        let r = RULES.iter().find(|r| r.id == sg).unwrap();
2207        assert_eq!((r.strength, r.basis, r.default), (Strength::Reference, Basis::Stated, Number(0.4)));
2208        assert!(r.needs_scan());
2209        // χ-sep-MEDI and χ-sep-iLSQR: the same values, under their own rule ids.
2210        let tukey = |a: SeparationAlgorithm, m: Manufacturer, t: f64| Case::new(|c| {
2211            separation(a)(c);
2212            c.facts.manufacturer = Some(m);
2213            c.cfg.tukey_window = t;
2214        }).ids().into_iter().filter(|id| id.contains(".tukey")).collect::<Vec<_>>();
2215        for a in [SeparationAlgorithm::ChiSepMedi, SeparationAlgorithm::ChiSepIlsqr] {
2216            assert_eq!(tukey(a, Siemens, 0.0), ["chi-sep.tukey-siemens-ge"], "{a:?}");
2217            assert_eq!(tukey(a, Ge, 0.0), ["chi-sep.tukey-siemens-ge"], "{a:?}");
2218            assert!(tukey(a, Ge, 0.4).is_empty(), "{a:?}");
2219            assert_eq!(tukey(a, Philips, 0.4), ["chi-sep.tukey-philips"], "{a:?}");
2220            assert!(tukey(a, Philips, 0.0).is_empty() && tukey(a, Other, 0.0).is_empty(), "{a:?}");
2221        }
2222        // Not with the other separation methods.
2223        for a in [SeparationAlgorithm::Decompose, SeparationAlgorithm::R2starQsm, SeparationAlgorithm::SusepNet] {
2224            assert!(tukey(a, Siemens, 0.0).is_empty(), "{a:?}");
2225        }
2226        for id in ["chi-sep.tukey-siemens-ge", "chi-sep.tukey-philips", "chi-sepnet.tukey-philips"] {
2227            assert_eq!(RULES.iter().find(|r| r.id == id).unwrap().kind(), Kind::Reference, "{id}");
2228        }
2229    }
2230
2231    /// The complex fit (MEDI's Fit_ppm_complex) suggests MEDI's region-growing unwrapper as the
2232    /// authors' setting: a reference rule, silent once followed or without the complex fit, and
2233    /// ROMEO stays the default unwrapper.
2234    #[test]
2235    fn complex_fit_suggests_medi_region_growing_as_the_authors_setting() {
2236        let id = "complex-fit.unwrap-region-growing";
2237        let r = RULES.iter().find(|r| r.id == id).unwrap();
2238        assert_eq!((r.kind(), r.basis, r.default), (Kind::Reference, Basis::ReferenceChoice, Choice("medi-region-growing")));
2239        let with = |b0: B0EstimationMethod, u: UnwrappingAlgorithm| Case::new(|c| {
2240            c.cfg.field_mapping.b0_estimation = b0;
2241            c.cfg.field_mapping.unwrapping_algorithm = u;
2242        }).ids().contains(&id);
2243        let fit = || B0EstimationMethod::ComplexFit(Default::default());
2244        assert!(with(fit(), UnwrappingAlgorithm::Romeo) && with(fit(), UnwrappingAlgorithm::Laplacian));
2245        assert!(!with(fit(), UnwrappingAlgorithm::MediRegionGrowing));
2246        assert!(!with(B0EstimationMethod::default(), UnwrappingAlgorithm::Romeo));
2247        assert_eq!(FieldMappingConfig::default().unwrapping_algorithm, UnwrappingAlgorithm::Romeo);
2248    }
2249
2250    #[test]
2251    fn manufacturers_are_read_as_dcm2niix_writes_them() {
2252        use Manufacturer::*;
2253        for (name, m) in [
2254            ("Siemens", Siemens), ("SIEMENS", Siemens), ("Siemens Healthineers", Siemens),
2255            ("GE", Ge), ("GE MEDICAL SYSTEMS", Ge), ("General Electric", Ge), (" ge ", Ge),
2256            ("Philips", Philips), ("Philips Medical Systems", Philips), ("Philips Healthcare", Philips),
2257            ("Canon_MEC", Other), ("TOSHIBA", Other), ("UIH", Other), ("GEMINI", Other), ("", Other),
2258        ] {
2259            assert_eq!(Manufacturer::from_name(name), m, "{name:?}");
2260        }
2261    }
2262
2263    /// The JSON form carries everything the evaluation uses.
2264    #[test]
2265    fn serialises_to_json() {
2266        let json = serde_json::to_value(RULES).unwrap();
2267        let star = json.as_array().unwrap().iter().find(|r| r["id"] == "star.padding").unwrap();
2268        assert_eq!(star["kind"], "reference");
2269        assert_eq!(star["strength"], "reference");
2270        assert_eq!(star["basis"], "reference_choice");
2271        assert_eq!(star["setting"], "inversion.star.pad");
2272        assert_eq!(star["unit"], "voxels");
2273        assert_eq!(star["when"][0]["on"], "setting");
2274        assert_eq!(star["when"][0]["is"]["test"], "one_of");
2275        assert_eq!(star["when"][0]["is"]["values"][0], "star");
2276        assert_eq!(star["expect"]["test"], "one_of");
2277        assert_eq!(star["default"], serde_json::json!([8.0, 8.0, 20.0]));
2278        let wasm = json.as_array().unwrap().iter().find(|r| r["id"] == "mindgrab.wasm-heap").unwrap();
2279        assert_eq!(wasm["when"][1]["on"], "fact");
2280        assert_eq!(wasm["when"][1]["fact"], "target");
2281
2282        let c = Case::new(|c| {
2283            c.cfg.inversion.algorithm = InversionAlgorithm::Star;
2284            c.cfg.inversion.star.pad = [12; 3];
2285        });
2286        let j = serde_json::to_value(evaluate(&c.input(), &c.facts)).unwrap();
2287        let pad = j.as_array().unwrap().iter().find(|f| f["id"] == "star.padding").unwrap();
2288        assert_eq!(pad["current"], serde_json::json!([[12.0, 12.0, 12.0]]));
2289        assert_eq!(pad["default"], serde_json::json!([8.0, 8.0, 20.0]));
2290    }
2291
2292    /// DECOMPOSE's per-echo QSM: recommended with DECOMPOSE (stated by its authors), satisfied
2293    /// by a per-echo reconstruction, and silent without DECOMPOSE.
2294    #[test]
2295    fn decompose_wants_a_qsm_per_echo() {
2296        let id = "decompose.qsm-per-echo";
2297        let r = RULES.iter().find(|r| r.id == id).unwrap();
2298        assert_eq!((r.strength, r.basis, r.default), (Strength::Recommended, Basis::Stated, Bool(true)));
2299        assert!(Case::new(separation(SeparationAlgorithm::Decompose)).ids().contains(&id));
2300        let on = Case::new(|c| { separation(SeparationAlgorithm::Decompose)(c); c.cfg.qsm_per_echo = true });
2301        assert!(!on.ids().contains(&id), "{:?}", on.ids());
2302        assert!(!Case::new(separation(SeparationAlgorithm::ChiSepIlsqr)).ids().contains(&id));
2303    }
2304
2305    /// DECOMPOSE's unwrapping is suggested at 7 T (a Siemens 7 T reports 6.98 T) and not at 3 T,
2306    /// nor when it is already on, nor before the field strength is known.
2307    #[test]
2308    fn decompose_unwrapping_at_7t_only() {
2309        let id = "decompose.unwrap-7t";
2310        let at = |b0: Option<f64>, unwrap: bool| Case::new(|c| {
2311            separation(SeparationAlgorithm::Decompose)(c);
2312            c.sep.as_mut().unwrap().decompose.unwrap_phase = unwrap;
2313            c.facts.field_strength = b0;
2314        }).ids().contains(&id);
2315        assert!(at(Some(7.0), false) && at(Some(6.98), false) && at(Some(9.4), false));
2316        assert!(!at(Some(3.0), false) && !at(Some(2.89), false) && !at(Some(1.5), false));
2317        assert!(!at(Some(7.0), true));
2318        assert!(!at(None, false));
2319        let r = RULES.iter().find(|r| r.id == id).unwrap();
2320        assert_eq!((r.strength, r.basis), (Strength::Recommended, Basis::Measured));
2321        assert!(r.needs_scan());
2322        // The authors' default stays: DecomposeParams leaves unwrapping off.
2323        assert!(!crate::separation::DecomposeParams::default().unwrap_phase);
2324    }
2325
2326    /// DECOMPOSE's echo-spacing χ bound is suggested at 7 T and not at 3 T, nor when it is already
2327    /// chosen, nor before the field strength is known; a fixed bound of any value reads as "ppm".
2328    #[test]
2329    fn decompose_chi_bound_at_7t_only() {
2330        use crate::separation::DecomposeChiBound as B;
2331        let id = "decompose.chi-bound-7t";
2332        let at = |b0: Option<f64>, bound: B| Case::new(|c| {
2333            separation(SeparationAlgorithm::Decompose)(c);
2334            c.sep.as_mut().unwrap().decompose.chi_bound = bound;
2335            c.facts.field_strength = b0;
2336        }).ids().contains(&id);
2337        assert!(at(Some(7.0), B::Ppm(0.5)) && at(Some(6.98), B::Ppm(0.3)) && at(Some(9.4), B::Ppm(0.5)));
2338        assert!(!at(Some(3.0), B::Ppm(0.5)) && !at(Some(1.5), B::Ppm(0.5)));
2339        assert!(!at(Some(7.0), B::EchoSpacing));
2340        assert!(!at(None, B::Ppm(0.5)));
2341        let r = RULES.iter().find(|r| r.id == id).unwrap();
2342        assert_eq!((r.strength, r.basis), (Strength::Recommended, Basis::Measured));
2343        assert_eq!(r.default, Value::Choice("echo-spacing"));
2344        assert!(r.needs_scan());
2345        // The authors' default stays: 0.5 ppm.
2346        assert_eq!(crate::separation::DecomposeParams::default().chi_bound, B::Ppm(0.5));
2347    }
2348
2349    /// QSM.jl's iSMV variant suggests QSM.jl's tolerance and cap; its own params pass, the
2350    /// default variant raises nothing, and "no cap" reads as +∞ (null in JSON).
2351    #[test]
2352    fn ismv_qsmjl_suggests_qsmjl_values() {
2353        use crate::bgremove::{IsmvParams, IsmvVariant};
2354        let ids = ["ismv-qsmjl.tolerance", "ismv-qsmjl.max-iterations"];
2355        let with = |p: IsmvParams| Case::new(|c| {
2356            c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Ismv;
2357            c.cfg.bg_removal.ismv = p;
2358        });
2359        let ismv_ids = |c: &Case| -> Vec<&'static str> { c.ids().into_iter().filter(|id| id.starts_with("ismv")).collect() };
2360        let variant_only = with(IsmvParams { variant: IsmvVariant::QsmJl, ..Default::default() });
2361        assert!(ids.iter().all(|id| variant_only.ids().contains(id)), "{:?}", variant_only.ids());
2362        let f = evaluate(&variant_only.input(), &variant_only.facts);
2363        let cap = f.iter().find(|f| f.rule.id == ids[1]).unwrap();
2364        assert_eq!(cap.current, vec![Current::Number(f64::INFINITY)]);
2365        assert!(serde_json::to_value(cap).unwrap()["current"][0].is_null());
2366        assert!(ismv_ids(&with(IsmvParams::qsmjl())).is_empty());
2367        assert!(ismv_ids(&with(IsmvParams::default())).is_empty());
2368        let p = IsmvParams::qsmjl();
2369        for r in RULES.iter().filter(|r| ids.contains(&r.id)) {
2370            assert_eq!(r.strength, Strength::Reference, "{}", r.id);
2371            let v = if r.setting == "bg_removal.ismv.tol" { p.tol } else { p.max_iter.unwrap() as f64 };
2372            assert_eq!(r.default, Number(v), "{}", r.id);
2373        }
2374        // Not in play without iSMV.
2375        let off = Case::new(|c| c.cfg.bg_removal.ismv.variant = IsmvVariant::QsmJl);
2376        assert!(off.ids().is_empty(), "{:?}", off.ids());
2377    }
2378
2379    /// LBV's tolerance is kept as given: at LBV.m's 0.01 nothing is said; another (the SOR
2380    /// solver's old 1e-6) gets LBV.m's value suggested; without LBV it is not in play.
2381    #[test]
2382    fn lbv_tolerance_is_suggested_not_rewritten() {
2383        let with = |tol: f64| Case::new(move |c| {
2384            c.cfg.bg_removal.algorithm = BgRemovalAlgorithm::Lbv;
2385            c.cfg.bg_removal.lbv.tol = tol;
2386        });
2387        assert!(!with(0.01).ids().contains(&"lbv.tolerance"));
2388        let old = with(1e-6);
2389        assert!(old.ids().contains(&"lbv.tolerance"), "{:?}", old.ids());
2390        let f = evaluate(&old.input(), &old.facts);
2391        let r = f.iter().find(|f| f.rule.id == "lbv.tolerance").unwrap();
2392        assert_eq!(r.current, vec![Current::Number(1e-6)]);
2393        assert_eq!(r.rule.strength, Strength::Reference);
2394        let off = Case::new(|c| c.cfg.bg_removal.lbv.tol = 1e-6);
2395        assert!(!off.ids().contains(&"lbv.tolerance"));
2396    }
2397
2398    /// On a per-echo reconstruction the settings that combine or compare echoes are not in play.
2399    #[test]
2400    fn per_echo_takes_echo_combination_out_of_play() {
2401        let combining = ["field_mapping.b0_estimation", "field_mapping.phase_offset_removal",
2402            "field_mapping.phase_offset_sigma_mm", "field_mapping.bipolar_correction",
2403            "field_mapping.b0_estimation.weighting"];
2404        let mut c = Case::new(|c| c.cfg.field_mapping.phase_offset_removal = true);
2405        for p in combining {
2406            assert!(!current(&c.input(), &c.facts, p).unwrap().is_empty(), "{p}");
2407        }
2408        c.cfg.qsm_per_echo = true;
2409        for p in combining {
2410            assert!(current(&c.input(), &c.facts, p).unwrap().is_empty(), "{p}");
2411        }
2412        assert!(!current(&c.input(), &c.facts, "field_mapping.unwrapping_algorithm").unwrap().is_empty());
2413        assert_eq!(current(&c.input(), &c.facts, "qsm_per_echo").unwrap(), vec![Current::Bool(true)]);
2414        // STI Suite's echo weighting is about how echoes are combined: silent per echo.
2415        let combined = Case::new(inversion(InversionAlgorithm::Star));
2416        assert!(combined.ids().contains(&"sti.echo-weights"), "{:?}", combined.ids());
2417        let per_echo = Case { cfg: QsmPipelineConfig { qsm_per_echo: true, ..combined.cfg.clone() }, ..combined.clone() };
2418        assert!(!per_echo.ids().contains(&"sti.echo-weights"), "{:?}", per_echo.ids());
2419    }
2420}