Skip to main content

cosmolkit_core/operations/
ops.rs

1//! Macro-controlled molecule operations.
2//!
3//! This module is the canonical operation-contract surface for molecular state
4//! migration. Operation bodies must be written as no-argument functions annotated
5//! with `#[mol_op_body(op_name, parts)]`; the attribute macro injects the only
6//! allowed mutable capability object.
7//!
8//! Agent guardrail: operation bodies must not take `&mut Molecule`, must not
9//! access `Molecule` storage directly, and must not update derived-state caches
10//! or invalidation flags by hand. If a future change appears to require bypassing
11//! those rules, the agent is not allowed to continue silently. It must stop and
12//! confirm the design exception with the human author before editing code.
13//!
14//! RDKit marker convention is defined in `dev/source_reproduction_protocol.md`.
15
16use std::fmt;
17
18use cosmolkit_macros::{mol_op_body, molecule_ops};
19
20use crate::{
21    AtomId, DerivedState, InvariantError, Molecule, MoleculeProperties, SupportStatus,
22    TopologyTrust,
23    invariants::enforce_molecule_invariants,
24    molecule::{CoordinateBlock, TopologyBlock, TopologyMapping},
25};
26
27pub(crate) use crate::read_parts::MoleculeReadParts;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum MoleculeOpKind {
31    Strong,
32    Weak,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum TopologyEditKind {
37    None,
38    Local,
39    Compacting,
40    Appending,
41    Renumbering,
42    Merge,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum OperationDomain {
47    Topology,
48    Coordinate,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ParityPolicy {
53    NotApplicable,
54    RequiredWhenSupported,
55    RequiredNow,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct BlockSet(u8);
60
61impl BlockSet {
62    pub const NONE: Self = Self(0);
63    pub const TOPOLOGY: Self = Self(1 << 0);
64    pub const COORDINATES: Self = Self(1 << 1);
65    pub const PROPERTIES: Self = Self(1 << 2);
66    pub const DERIVED_CACHE: Self = Self(1 << 3);
67
68    #[must_use]
69    pub const fn union(self, other: Self) -> Self {
70        Self(self.0 | other.0)
71    }
72
73    #[must_use]
74    pub const fn contains(self, other: Self) -> bool {
75        (self.0 & other.0) == other.0
76    }
77
78    #[must_use]
79    pub const fn intersects(self, other: Self) -> bool {
80        (self.0 & other.0) != 0
81    }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct BlockAccess {
86    read: BlockSet,
87    write: BlockSet,
88}
89
90impl BlockAccess {
91    pub const NONE: Self = Self {
92        read: BlockSet::NONE,
93        write: BlockSet::NONE,
94    };
95
96    #[must_use]
97    pub const fn new(read: BlockSet, write: BlockSet) -> Self {
98        Self { read, write }
99    }
100
101    #[must_use]
102    pub const fn read(self) -> BlockSet {
103        self.read
104    }
105
106    #[must_use]
107    pub const fn write(self) -> BlockSet {
108        self.write
109    }
110
111    #[must_use]
112    pub const fn can_read(self, block: BlockSet) -> bool {
113        self.read.contains(block) || self.write.contains(block)
114    }
115
116    #[must_use]
117    pub const fn can_write(self, block: BlockSet) -> bool {
118        self.write.contains(block)
119    }
120
121    #[must_use]
122    pub const fn has_overlapping_read_write(self) -> bool {
123        self.read.intersects(self.write)
124    }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum PreservationProof {
129    LeafAtomAppend,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct DerivedEffects {
134    recompute: DerivedState,
135    preserve: DerivedState,
136    invalidate: DerivedState,
137}
138
139impl DerivedEffects {
140    pub const NONE: Self = Self {
141        recompute: DerivedState::NONE,
142        preserve: DerivedState::NONE,
143        invalidate: DerivedState::NONE,
144    };
145
146    #[must_use]
147    pub const fn new(
148        recompute: DerivedState,
149        preserve: DerivedState,
150        invalidate: DerivedState,
151    ) -> Self {
152        Self {
153            recompute,
154            preserve,
155            invalidate,
156        }
157    }
158
159    #[must_use]
160    pub const fn recompute(self) -> DerivedState {
161        self.recompute
162    }
163
164    #[must_use]
165    pub const fn preserve(self) -> DerivedState {
166        self.preserve
167    }
168
169    #[must_use]
170    pub const fn invalidate(self) -> DerivedState {
171        self.invalidate
172    }
173
174    #[must_use]
175    pub const fn needs_update(self) -> DerivedState {
176        self.invalidate.union(self.recompute)
177    }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum MappingRequirement {
182    None,
183    Identity,
184    Required,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum SemanticPrecondition {
189    TrustedBondTopology,
190    HydrogenOwnershipRepresented,
191    ValenceComputable,
192}
193
194impl fmt::Display for SemanticPrecondition {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        match self {
197            Self::TrustedBondTopology => f.write_str("trusted_bond_topology"),
198            Self::HydrogenOwnershipRepresented => f.write_str("hydrogen_ownership_represented"),
199            Self::ValenceComputable => f.write_str("valence_computable"),
200        }
201    }
202}
203
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub struct SemanticPreconditionSet(u8);
206
207impl SemanticPreconditionSet {
208    pub const NONE: Self = Self(0);
209    pub const TRUSTED_BOND_TOPOLOGY: Self = Self(1 << 0);
210    pub const HYDROGEN_OWNERSHIP_REPRESENTED: Self = Self(1 << 1);
211    pub const VALENCE_COMPUTABLE: Self = Self(1 << 2);
212
213    #[must_use]
214    pub const fn union(self, other: Self) -> Self {
215        Self(self.0 | other.0)
216    }
217
218    #[must_use]
219    pub const fn contains(self, other: Self) -> bool {
220        (self.0 & other.0) == other.0
221    }
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub struct MoleculeOpSpec {
226    pub method: &'static str,
227    pub impl_fn: &'static str,
228    pub domain: OperationDomain,
229    pub kind: MoleculeOpKind,
230    pub topology_edit: TopologyEditKind,
231    pub access: BlockAccess,
232    pub may_mutate: BlockSet,
233    pub auto_remap: BlockSet,
234    pub derived_effects: DerivedEffects,
235    pub semantic_preconditions: SemanticPreconditionSet,
236    pub requires_mapping: MappingRequirement,
237    pub allows_noop: bool,
238    pub support: SupportStatus,
239    pub parity: ParityPolicy,
240    pub io_roundtrip: bool,
241}
242
243impl fmt::Display for MoleculeOpSpec {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        f.write_str(self.method)
246    }
247}
248
249impl MoleculeOpSpec {
250    #[must_use]
251    pub const fn needs_update(self: &Self) -> DerivedState {
252        self.derived_effects.needs_update()
253    }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum OpOutcome {
258    Changed,
259    NoOp { reason: &'static str },
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
263pub enum OperationError {
264    #[error("{operation}: unsupported operation: {reason}")]
265    Unsupported {
266        operation: &'static MoleculeOpSpec,
267        reason: &'static str,
268    },
269    #[error("{operation}: invalid input: {message}")]
270    InvalidInput {
271        operation: &'static MoleculeOpSpec,
272        message: &'static str,
273    },
274    #[error("{operation}: semantic precondition `{requirement}` failed: {message}")]
275    Precondition {
276        operation: &'static MoleculeOpSpec,
277        requirement: SemanticPrecondition,
278        message: &'static str,
279    },
280    #[error("{operation}: chemistry error: {message}")]
281    Chemistry {
282        operation: &'static MoleculeOpSpec,
283        message: &'static str,
284    },
285    #[error("{operation}: distance-geometry error: {source}")]
286    DistanceGeometry {
287        operation: &'static MoleculeOpSpec,
288        #[source]
289        source: crate::DgBoundsError,
290    },
291    #[error("{operation}: {source}")]
292    UnsupportedFeature {
293        operation: &'static MoleculeOpSpec,
294        #[source]
295        source: crate::UnsupportedFeatureError,
296    },
297    #[error("{operation}: valence error: {source}")]
298    Valence {
299        operation: &'static MoleculeOpSpec,
300        #[source]
301        source: crate::ValenceError,
302    },
303    #[error("{operation}: ring finding error: {source}")]
304    RingFinding {
305        operation: &'static MoleculeOpSpec,
306        #[source]
307        source: crate::RingFindingError,
308    },
309    #[error("{operation}: aromaticity error: {source}")]
310    Aromaticity {
311        operation: &'static MoleculeOpSpec,
312        #[source]
313        source: crate::AromaticityError,
314    },
315    #[error("{operation}: {source}")]
316    Sanitize {
317        operation: &'static MoleculeOpSpec,
318        #[source]
319        source: crate::SanitizeError,
320    },
321    #[error("{operation}: kekulize error: {source}")]
322    Kekulize {
323        operation: &'static MoleculeOpSpec,
324        #[source]
325        source: crate::KekulizeError,
326    },
327    #[error("{operation}: hydrogen removal error: {source}")]
328    RemoveHydrogens {
329        operation: &'static MoleculeOpSpec,
330        #[source]
331        source: crate::RemoveHydrogensError,
332    },
333    #[error("{operation}: hydrogen addition error: {source}")]
334    AddHydrogens {
335        operation: &'static MoleculeOpSpec,
336        #[source]
337        source: crate::AddHydrogensError,
338    },
339    #[error("{operation}: invariant violation: {failure}")]
340    InvariantViolation {
341        operation: &'static MoleculeOpSpec,
342        failure: InvariantError,
343    },
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub struct InvariantCheckSet(u32);
348
349impl InvariantCheckSet {
350    pub const NONE: Self = Self(0);
351    pub const GRAPH_INDEX_VALIDITY: Self = Self(1 << 0);
352    pub const COORDINATE_ROW_ALIGNMENT: Self = Self(1 << 1);
353    pub const MAPPING_RECORDED_OR_EXPLICITLY_UNSUPPORTED: Self = Self(1 << 2);
354    pub const STEREO_VALIDITY: Self = Self(1 << 3);
355    pub const CACHE_INVALIDATION: Self = Self(1 << 4);
356    pub const PROPERTY_POLICY: Self = Self(1 << 5);
357    pub const SOURCE_UNCHANGED: Self = Self(1 << 6);
358    pub const UNSUPPORTED_IS_EXPLICIT: Self = Self(1 << 7);
359
360    #[must_use]
361    pub const fn union(self, other: Self) -> Self {
362        Self(self.0 | other.0)
363    }
364}
365
366#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub struct SupportMatrixEntry {
368    pub feature: &'static crate::FeatureSpec,
369    pub operation: Option<&'static MoleculeOpSpec>,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub struct OperationInvariantEntry {
374    pub operation: &'static MoleculeOpSpec,
375    pub profile: &'static str,
376    pub required_checks: InvariantCheckSet,
377}
378
379impl OperationInvariantEntry {
380    #[must_use]
381    pub const fn for_operation(operation: &'static MoleculeOpSpec, profile: &'static str) -> Self {
382        Self {
383            operation,
384            profile,
385            required_checks: InvariantCheckSet::GRAPH_INDEX_VALIDITY
386                .union(InvariantCheckSet::COORDINATE_ROW_ALIGNMENT)
387                .union(InvariantCheckSet::MAPPING_RECORDED_OR_EXPLICITLY_UNSUPPORTED)
388                .union(InvariantCheckSet::STEREO_VALIDITY)
389                .union(InvariantCheckSet::CACHE_INVALIDATION)
390                .union(InvariantCheckSet::PROPERTY_POLICY)
391                .union(InvariantCheckSet::SOURCE_UNCHANGED)
392                .union(InvariantCheckSet::UNSUPPORTED_IS_EXPLICIT),
393        }
394    }
395}
396
397#[derive(Debug, Clone, Copy, PartialEq, Eq)]
398pub struct ParityMatrixEntry {
399    pub operation: &'static MoleculeOpSpec,
400    pub feature: &'static crate::FeatureSpec,
401    pub profile: &'static str,
402    pub rdkit_version: Option<&'static str>,
403}
404
405molecule_ops! {
406    op with_hydrogens(params: crate::hydrogens::AddHsParams) {
407        method: with_hydrogens_with_params,
408        impl_fn: with_hydrogens_impl,
409        default_method: with_hydrogens,
410        default_args: [crate::hydrogens::AddHsParams::default()],
411        inplace: true,
412        inplace_method: add_hydrogens_with_params_,
413        default_inplace_method: add_hydrogens_,
414        domain: topology,
415        kind: strong,
416        topology_edit: appending,
417        access: { read: [], write: [topology, coordinates, properties, derived_cache] },
418        may_mutate: [topology, coordinates, properties, derived_cache],
419        auto_remap: [coordinates, properties],
420        derived_effects: {
421            recompute: [],
422            preserve: [rings, ring_families],
423            invalidate: [valence, aromaticity, stereo, drawing, fingerprint],
424        },
425        semantic_preconditions: [
426            trusted_bond_topology,
427            hydrogen_ownership_represented,
428            valence_computable,
429        ],
430        requires_mapping: required,
431        allows_noop: false,
432        feature: HYDROGENS_FEATURE,
433        parity: required_when_supported,
434        io_roundtrip: true,
435        invariant_profile: "strong_topology_with_coordinates",
436        parity_profile: "add_hs_default_rdkit",
437    }
438
439    op without_hydrogens(sanitize: bool) {
440        method: without_hydrogens_with_sanitize,
441        impl_fn: without_hydrogens_impl,
442        default_method: without_hydrogens,
443        default_args: [true],
444        inplace: true,
445        inplace_method: remove_hydrogens_with_sanitize_,
446        default_inplace_method: remove_hydrogens_,
447        domain: topology,
448        kind: strong,
449        topology_edit: compacting,
450        access: { read: [], write: [topology, coordinates, properties, derived_cache] },
451        may_mutate: [topology, coordinates, properties, derived_cache],
452        auto_remap: [coordinates, properties],
453        derived_effects: {
454            recompute: [valence, aromaticity, rings],
455            preserve: [],
456            invalidate: [ring_families, stereo, drawing, fingerprint],
457        },
458        requires_mapping: required,
459        allows_noop: true,
460        feature: HYDROGENS_FEATURE,
461        parity: required_when_supported,
462        io_roundtrip: true,
463        invariant_profile: "strong_topology_with_coordinates",
464        parity_profile: "remove_hs_default_rdkit",
465    }
466
467    op without_hydrogens_with_params(params: crate::hydrogens::RemoveHsParams, sanitize: bool) {
468        method: without_hydrogens_with_params,
469        impl_fn: without_hydrogens_with_params_impl,
470        inplace: true,
471        inplace_method: remove_hydrogens_with_params_,
472        domain: topology,
473        kind: strong,
474        topology_edit: compacting,
475        access: { read: [], write: [topology, coordinates, properties, derived_cache] },
476        may_mutate: [topology, coordinates, properties, derived_cache],
477        auto_remap: [coordinates, properties],
478        derived_effects: {
479            recompute: [valence, aromaticity, rings],
480            preserve: [],
481            invalidate: [ring_families, stereo, drawing, fingerprint],
482        },
483        requires_mapping: required,
484        allows_noop: true,
485        feature: HYDROGENS_FEATURE,
486        parity: required_when_supported,
487        io_roundtrip: true,
488        invariant_profile: "strong_topology_with_coordinates",
489        parity_profile: "remove_hs_parameterized_rdkit",
490    }
491
492    op with_kekulized_bonds(clear_aromatic_flags: bool) {
493        method: with_kekulized_bonds,
494        impl_fn: with_kekulized_bonds_impl,
495        inplace: true,
496        inplace_method: kekulize_,
497        domain: topology,
498        kind: weak,
499        topology_edit: local,
500        access: { read: [], write: [topology, derived_cache] },
501        may_mutate: [topology, derived_cache],
502        auto_remap: [],
503        derived_effects: {
504            recompute: [rings, valence],
505            preserve: [],
506            invalidate: [aromaticity, drawing, fingerprint],
507        },
508        requires_mapping: none,
509        allows_noop: true,
510        feature: KEKULIZE_FEATURE,
511        parity: required_when_supported,
512        io_roundtrip: true,
513        invariant_profile: "weak_topology_state",
514        parity_profile: "kekulize_clear_aromatic_flags",
515    }
516
517    op sanitize(ops: crate::SanitizeOps) {
518        method: sanitize_with_ops,
519        impl_fn: sanitize_impl,
520        default_method: sanitize,
521        default_args: [crate::SanitizeOps::ALL],
522        inplace: true,
523        inplace_method: sanitize_with_ops_,
524        default_inplace_method: sanitize_,
525        domain: topology,
526        kind: weak,
527        topology_edit: local,
528        access: { read: [], write: [topology, derived_cache] },
529        may_mutate: [topology, derived_cache],
530        auto_remap: [],
531        derived_effects: {
532            recompute: [rings, valence, aromaticity],
533            preserve: [],
534            invalidate: [ring_families, stereo, drawing, fingerprint],
535        },
536        requires_mapping: none,
537        allows_noop: true,
538        feature: SANITIZE_FEATURE,
539        parity: required_when_supported,
540        io_roundtrip: true,
541        invariant_profile: "weak_topology_state",
542        parity_profile: "sanitize_default_rdkit",
543    }
544
545    op assigned_valence() {
546        method: with_assigned_valence,
547        impl_fn: assigned_valence_impl,
548        inplace: true,
549        inplace_method: assign_valence_,
550        domain: topology,
551        kind: weak,
552        topology_edit: none,
553        access: { read: [topology], write: [derived_cache] },
554        may_mutate: [derived_cache],
555        auto_remap: [],
556        derived_effects: {
557            recompute: [valence],
558            preserve: [],
559            invalidate: [],
560        },
561        requires_mapping: none,
562        allows_noop: true,
563        feature: VALENCE_FEATURE,
564        parity: required_when_supported,
565        io_roundtrip: true,
566        invariant_profile: "weak_derived_cache_update",
567        parity_profile: "update_property_cache_rdkit",
568    }
569
570    op assigned_rings() {
571        method: with_assigned_rings,
572        impl_fn: assigned_rings_impl,
573        inplace: true,
574        inplace_method: assign_rings_,
575        domain: topology,
576        kind: weak,
577        topology_edit: none,
578        access: { read: [topology], write: [derived_cache] },
579        may_mutate: [derived_cache],
580        auto_remap: [],
581        derived_effects: {
582            recompute: [rings],
583            preserve: [],
584            invalidate: [],
585        },
586        requires_mapping: none,
587        allows_noop: true,
588        feature: RINGS_FEATURE,
589        parity: required_when_supported,
590        io_roundtrip: true,
591        invariant_profile: "weak_derived_cache_update",
592        parity_profile: "symmetrize_sssr_rdkit",
593    }
594
595    op assigned_ring_families() {
596        method: with_assigned_ring_families,
597        impl_fn: assigned_ring_families_impl,
598        inplace: true,
599        inplace_method: assign_ring_families_,
600        domain: topology,
601        kind: weak,
602        topology_edit: none,
603        access: { read: [topology], write: [derived_cache] },
604        may_mutate: [derived_cache],
605        auto_remap: [],
606        derived_effects: {
607            recompute: [ring_families],
608            preserve: [],
609            invalidate: [],
610        },
611        requires_mapping: none,
612        allows_noop: true,
613        feature: RINGS_FEATURE,
614        parity: required_when_supported,
615        io_roundtrip: true,
616        invariant_profile: "weak_derived_cache_update",
617        parity_profile: "ring_families_rdkit_urf",
618    }
619
620    op assigned_aromaticity() {
621        method: with_assigned_aromaticity,
622        impl_fn: assigned_aromaticity_impl,
623        inplace: true,
624        inplace_method: assign_aromaticity_,
625        domain: topology,
626        kind: weak,
627        topology_edit: local,
628        access: { read: [], write: [topology, derived_cache] },
629        may_mutate: [topology, derived_cache],
630        auto_remap: [],
631        derived_effects: {
632            recompute: [rings, valence, aromaticity],
633            preserve: [],
634            invalidate: [drawing, fingerprint],
635        },
636        requires_mapping: none,
637        allows_noop: true,
638        feature: AROMATICITY_FEATURE,
639        parity: required_when_supported,
640        io_roundtrip: true,
641        invariant_profile: "weak_topology_state",
642        parity_profile: "set_aromaticity_default_rdkit",
643    }
644
645    op assigned_radicals() {
646        method: with_assigned_radicals,
647        impl_fn: assigned_radicals_impl,
648        inplace: true,
649        inplace_method: assign_radicals_,
650        domain: topology,
651        kind: weak,
652        topology_edit: local,
653        access: { read: [], write: [topology, derived_cache] },
654        may_mutate: [topology, derived_cache],
655        auto_remap: [],
656        derived_effects: {
657            recompute: [valence],
658            preserve: [],
659            invalidate: [],
660        },
661        requires_mapping: none,
662        allows_noop: true,
663        feature: VALENCE_FEATURE,
664        parity: required_when_supported,
665        io_roundtrip: true,
666        invariant_profile: "weak_topology_state",
667        parity_profile: "assign_radicals_rdkit",
668    }
669
670    op with_2d_coordinates(params: crate::With2DCoordinatesParams) {
671        method: with_2d_coordinates_with_params,
672        impl_fn: with_2d_coordinates_impl,
673        default_method: with_2d_coordinates,
674        default_args: [crate::With2DCoordinatesParams::default()],
675        inplace: true,
676        inplace_method: compute_2d_coordinates_with_params_,
677        default_inplace_method: compute_2d_coordinates_,
678        domain: coordinate,
679        kind: weak,
680        topology_edit: none,
681        access: { read: [topology], write: [coordinates] },
682        may_mutate: [coordinates],
683        auto_remap: [],
684        semantic_preconditions: [trusted_bond_topology],
685        derived_effects: {
686            recompute: [],
687            preserve: [],
688            invalidate: [drawing],
689        },
690        requires_mapping: none,
691        allows_noop: true,
692        feature: COORDINATE_EDIT_FEATURE,
693        parity: required_when_supported,
694        io_roundtrip: true,
695        invariant_profile: "coordinate_generation",
696        parity_profile: "compute_2d_coords_default_rdkit",
697    }
698
699    op with_3d_conformer(params: crate::EmbedParameters) {
700        method: with_3d_conformer_with_params,
701        impl_fn: with_3d_conformer_impl,
702        default_method: with_3d_conformer,
703        default_args: [crate::EmbedParameters::etkdg_v3()],
704        inplace: true,
705        inplace_method: embed_3d_conformer_with_params_,
706        default_inplace_method: embed_3d_conformer_,
707        domain: coordinate,
708        kind: weak,
709        topology_edit: none,
710        access: { read: [topology], write: [coordinates] },
711        may_mutate: [coordinates],
712        auto_remap: [],
713        semantic_preconditions: [trusted_bond_topology],
714        derived_effects: {
715            recompute: [],
716            preserve: [],
717            invalidate: [drawing],
718        },
719        requires_mapping: none,
720        allows_noop: true,
721        feature: CONFORMER_GENERATION_FEATURE,
722        parity: required_when_supported,
723        io_roundtrip: true,
724        invariant_profile: "coordinate_generation_3d",
725        parity_profile: "embed_molecule_etkdgv3_rdkit",
726    }
727
728    op with_3d_conformers(num_confs: usize, params: crate::EmbedParameters) {
729        method: with_3d_conformers_with_params,
730        impl_fn: with_3d_conformers_impl,
731        inplace: true,
732        inplace_method: embed_3d_conformers_with_params_,
733        domain: coordinate,
734        kind: weak,
735        topology_edit: none,
736        access: { read: [topology], write: [coordinates] },
737        may_mutate: [coordinates],
738        auto_remap: [],
739        semantic_preconditions: [trusted_bond_topology],
740        derived_effects: {
741            recompute: [],
742            preserve: [],
743            invalidate: [drawing],
744        },
745        requires_mapping: none,
746        allows_noop: true,
747        feature: CONFORMER_GENERATION_FEATURE,
748        parity: required_when_supported,
749        io_roundtrip: true,
750        invariant_profile: "coordinate_generation_3d_multi",
751        parity_profile: "embed_multiple_confs_rdkit",
752    }
753
754    op with_2d_coordinate_block(coords: Vec<[f64; 2]>) {
755        method: with_2d_coordinate_block,
756        impl_fn: with_2d_coordinate_block_impl,
757        inplace: true,
758        inplace_method: set_2d_coordinate_block_,
759        domain: coordinate,
760        kind: weak,
761        topology_edit: none,
762        access: { read: [topology], write: [coordinates] },
763        may_mutate: [coordinates],
764        auto_remap: [],
765        derived_effects: {
766            recompute: [],
767            preserve: [],
768            invalidate: [drawing],
769        },
770        requires_mapping: none,
771        allows_noop: false,
772        feature: COORDINATE_EDIT_FEATURE,
773        parity: required_when_supported,
774        io_roundtrip: true,
775        invariant_profile: "coordinate_set_2d",
776        parity_profile: "set_2d_coordinates_manual",
777    }
778
779    op with_3d_coordinates(coords: Vec<[f64; 3]>, conformer_index: usize) {
780        method: with_3d_coordinates,
781        impl_fn: with_3d_coordinates_impl,
782        inplace: true,
783        inplace_method: set_3d_coordinates_,
784        domain: coordinate,
785        kind: weak,
786        topology_edit: none,
787        access: { read: [topology], write: [coordinates] },
788        may_mutate: [coordinates],
789        auto_remap: [],
790        derived_effects: {
791            recompute: [],
792            preserve: [],
793            invalidate: [drawing],
794        },
795        requires_mapping: none,
796        allows_noop: false,
797        feature: COORDINATE_EDIT_FEATURE,
798        parity: required_when_supported,
799        io_roundtrip: true,
800        invariant_profile: "coordinate_set_3d",
801        parity_profile: "set_3d_coordinates_manual",
802    }
803
804    op with_cleared_3d_conformers() {
805        method: with_cleared_3d_conformers,
806        impl_fn: with_cleared_3d_conformers_impl,
807        inplace: true,
808        inplace_method: clear_3d_conformers_,
809        domain: coordinate,
810        kind: weak,
811        topology_edit: none,
812        access: { read: [], write: [coordinates] },
813        may_mutate: [coordinates],
814        auto_remap: [],
815        derived_effects: {
816            recompute: [],
817            preserve: [],
818            invalidate: [drawing],
819        },
820        requires_mapping: none,
821        allows_noop: true,
822        feature: COORDINATE_EDIT_FEATURE,
823        parity: required_when_supported,
824        io_roundtrip: true,
825        invariant_profile: "coordinate_clear_3d_conformers",
826        parity_profile: "remove_all_conformers_manual",
827    }
828
829    op with_added_3d_conformer(coords: Vec<[f64; 3]>, is_3d: bool) {
830        method: with_added_3d_conformer,
831        impl_fn: with_added_3d_conformer_impl,
832        inplace: true,
833        inplace_method: add_3d_conformer_,
834        domain: coordinate,
835        kind: weak,
836        topology_edit: none,
837        access: { read: [topology], write: [coordinates] },
838        may_mutate: [coordinates],
839        auto_remap: [],
840        derived_effects: {
841            recompute: [],
842            preserve: [],
843            invalidate: [drawing],
844        },
845        requires_mapping: none,
846        allows_noop: false,
847        feature: COORDINATE_EDIT_FEATURE,
848        parity: required_when_supported,
849        io_roundtrip: true,
850        invariant_profile: "coordinate_add_3d_conformer",
851        parity_profile: "add_3d_conformer_manual",
852    }
853
854    op with_only_3d_conformer(coords: Vec<[f64; 3]>, is_3d: bool) {
855        method: with_only_3d_conformer,
856        impl_fn: with_only_3d_conformer_impl,
857        inplace: true,
858        inplace_method: set_only_3d_conformer_,
859        domain: coordinate,
860        kind: weak,
861        topology_edit: none,
862        access: { read: [topology], write: [coordinates] },
863        may_mutate: [coordinates],
864        auto_remap: [],
865        derived_effects: {
866            recompute: [],
867            preserve: [],
868            invalidate: [drawing],
869        },
870        requires_mapping: none,
871        allows_noop: false,
872        feature: COORDINATE_EDIT_FEATURE,
873        parity: required_when_supported,
874        io_roundtrip: true,
875        invariant_profile: "coordinate_set_single_3d_conformer",
876        parity_profile: "remove_all_then_add_conformer_manual",
877    }
878}
879
880mod bodies;
881pub(crate) mod hydrogens;
882mod runtime;
883mod sanitize_pipeline;
884
885use self::runtime::OpParts;
886pub use self::runtime::OperationTrace;
887use self::{bodies::*, hydrogens::*, sanitize_pipeline::*};
888
889pub(crate) use self::sanitize_pipeline::{
890    sanitize_conjugation_assignment, sanitize_hybridization_assignment,
891};
892
893#[cfg(test)]
894mod tests {
895    use std::collections::HashSet;
896
897    use super::*;
898    use crate::{BondOrder, BondQueryPredicate, QueryNode};
899
900    const TEST_NEEDS_VALENCE_UPDATE_SPEC: MoleculeOpSpec = MoleculeOpSpec {
901        method: "test_needs_valence_update",
902        impl_fn: "test_needs_valence_update_impl",
903        domain: OperationDomain::Topology,
904        kind: MoleculeOpKind::Weak,
905        topology_edit: TopologyEditKind::None,
906        access: BlockAccess::new(BlockSet::NONE, BlockSet::DERIVED_CACHE),
907        may_mutate: BlockSet::DERIVED_CACHE,
908        auto_remap: BlockSet::NONE,
909        semantic_preconditions: SemanticPreconditionSet::NONE,
910        derived_effects: DerivedEffects::new(
911            DerivedState::NONE,                               // recompute
912            DerivedState::NONE,                               // preserve
913            DerivedState::VALENCE.union(DerivedState::RINGS), // invalidate: needs_update target + clear-permitted
914        ),
915        requires_mapping: MappingRequirement::None,
916        allows_noop: true,
917        support: SupportStatus::Experimental,
918        parity: ParityPolicy::NotApplicable,
919        io_roundtrip: false,
920    };
921
922    const TEST_RECOMPUTE_VALENCE_SPEC: MoleculeOpSpec = MoleculeOpSpec {
923        method: "test_recompute_valence",
924        impl_fn: "test_recompute_valence_impl",
925        domain: OperationDomain::Topology,
926        kind: MoleculeOpKind::Weak,
927        topology_edit: TopologyEditKind::None,
928        access: BlockAccess::new(BlockSet::NONE, BlockSet::DERIVED_CACHE),
929        may_mutate: BlockSet::DERIVED_CACHE,
930        auto_remap: BlockSet::NONE,
931        semantic_preconditions: SemanticPreconditionSet::NONE,
932        derived_effects: DerivedEffects::new(
933            DerivedState::VALENCE, // recompute
934            DerivedState::NONE,    // preserve
935            DerivedState::NONE,    // invalidate
936        ),
937        requires_mapping: MappingRequirement::None,
938        allows_noop: true,
939        support: SupportStatus::Experimental,
940        parity: ParityPolicy::NotApplicable,
941        io_roundtrip: false,
942    };
943
944    const TEST_OVERLAPPING_ACCESS_SPEC: MoleculeOpSpec = MoleculeOpSpec {
945        method: "test_overlapping_access",
946        impl_fn: "test_overlapping_access_impl",
947        domain: OperationDomain::Topology,
948        kind: MoleculeOpKind::Weak,
949        topology_edit: TopologyEditKind::Local,
950        access: BlockAccess::new(BlockSet::TOPOLOGY, BlockSet::TOPOLOGY),
951        may_mutate: BlockSet::TOPOLOGY,
952        auto_remap: BlockSet::NONE,
953        semantic_preconditions: SemanticPreconditionSet::NONE,
954        derived_effects: DerivedEffects::NONE,
955        requires_mapping: MappingRequirement::None,
956        allows_noop: true,
957        support: SupportStatus::Experimental,
958        parity: ParityPolicy::NotApplicable,
959        io_roundtrip: false,
960    };
961
962    #[test]
963    fn molecule_read_parts_does_not_expose_raw_molecule_escape() {
964        let ops_source = include_str!("ops.rs");
965        let read_parts_source = include_str!("../model/read_parts.rs");
966        assert!(!ops_source.contains(concat!("Molecule", "ReadAccess")));
967        assert!(!ops_source.contains(concat!("read_parts", ".", "molecule")));
968        assert!(!ops_source.contains(concat!(".", "molecule", "()")));
969        assert!(!read_parts_source.contains(concat!("with_", "molecule", "_read")));
970        assert!(!read_parts_source.contains(concat!("pub(crate) fn ", "molecule")));
971    }
972
973    fn op_body_sources() -> [(&'static str, &'static str); 3] {
974        [
975            ("bodies.rs", include_str!("ops/bodies.rs")),
976            ("hydrogens.rs", include_str!("ops/hydrogens.rs")),
977            (
978                "sanitize_pipeline.rs",
979                include_str!("ops/sanitize_pipeline.rs"),
980            ),
981        ]
982    }
983
984    fn assert_sources_do_not_contain(sources: &[(&str, &str)], forbidden: &[&str]) {
985        for (name, source) in sources {
986            for pattern in forbidden {
987                assert!(
988                    !source.contains(pattern),
989                    "{name} must not contain forbidden operation-body pattern `{pattern}`"
990                );
991            }
992        }
993    }
994
995    fn parts_method_names(source: &str) -> Vec<&str> {
996        let mut names = Vec::new();
997        let mut remaining = source;
998        while let Some(index) = remaining.find("parts.") {
999            let after = &remaining[index + "parts.".len()..];
1000            let len = after
1001                .find(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_'))
1002                .unwrap_or(after.len());
1003            if len > 0 {
1004                names.push(&after[..len]);
1005            }
1006            remaining = &after[len..];
1007        }
1008        names
1009    }
1010
1011    #[test]
1012    fn op_parts_working_state_is_runtime_private() {
1013        let ops_source = include_str!("ops.rs");
1014        let runtime_source = include_str!("ops/runtime.rs");
1015
1016        assert!(ops_source.contains("mod runtime;"));
1017        assert!(runtime_source.contains("pub struct OpParts"));
1018        assert!(runtime_source.contains("working: Molecule"));
1019        assert!(ops_source.contains(concat!("use self::runtime::", "OpParts;")));
1020        assert!(!ops_source.contains(concat!("pub(crate) use self::runtime::", "OpParts")));
1021        assert!(!ops_source.contains(concat!("pub use self::runtime::", "OpParts")));
1022    }
1023
1024    #[test]
1025    fn operation_body_modules_do_not_access_runtime_or_raw_molecule_escape_hatches() {
1026        let sources = op_body_sources();
1027        assert_sources_do_not_contain(
1028            &sources,
1029            &[
1030                concat!("parts", ".", "working"),
1031                concat!(".", "working"),
1032                concat!("parts", ".", "spec"),
1033                concat!("&", "Molecule", ","),
1034                concat!("&", "Molecule", ")"),
1035                concat!("&", "Molecule", "\n"),
1036                concat!("Molecule", "::"),
1037                concat!("MoleculeReadParts", "::", "from_molecule"),
1038                concat!("from_", "operation", "_blocks"),
1039                concat!("read_parts", "_for_", "topology"),
1040                concat!("read_parts", "_for_", "blocks"),
1041                concat!("read_parts", "_for_", "optional", "_blocks"),
1042                concat!("OpParts", "::", "new"),
1043                concat!("new_", "in_place"),
1044            ],
1045        );
1046    }
1047
1048    #[test]
1049    fn operation_body_parts_usage_stays_within_capability_methods() {
1050        const ALLOWED: &[&str] = &[
1051            "begin_topology_read",
1052            "begin_topology_mut",
1053            "commit_topology",
1054            "begin_coordinates_mut",
1055            "commit_coordinates",
1056            "begin_properties_mut",
1057            "commit_properties",
1058            "record_topology_edit",
1059            "record_topology_mapping",
1060            "clear_cache",
1061            "set_rings_cache",
1062            "set_ring_families_cache",
1063            "set_valence_cache",
1064            "mark_aromaticity_valid",
1065            "prove_preserved",
1066            "with_topology_read_parts",
1067            "with_block_read_parts",
1068            "with_optional_block_read_parts",
1069        ];
1070
1071        for (source_name, source) in op_body_sources() {
1072            for method in parts_method_names(source) {
1073                assert!(
1074                    ALLOWED.contains(&method),
1075                    "{source_name} uses non-capability OpParts method `{method}`"
1076                );
1077            }
1078        }
1079    }
1080
1081    #[test]
1082    fn distgeom_operation_bodies_use_coordinate_update_boundary() {
1083        let bodies_source = include_str!("ops/bodies.rs");
1084        assert!(bodies_source.contains("embed_molecule_coordinate_update"));
1085        assert!(bodies_source.contains("embed_multiple_confs_coordinate_update"));
1086        assert!(!bodies_source.contains(concat!("embed_", "molecule", "(")));
1087        assert!(!bodies_source.contains(concat!("embed_", "multiple", "_confs", "(")));
1088        assert!(!bodies_source.contains(concat!("working", ".", "clone", "()")));
1089    }
1090
1091    #[test]
1092    fn mol_op_body_functions_live_outside_runtime_module() {
1093        let runtime_source = include_str!("ops/runtime.rs");
1094        let bodies_source = include_str!("ops/bodies.rs");
1095        let hydrogens_source = include_str!("ops/hydrogens.rs");
1096        let sanitize_source = include_str!("ops/sanitize_pipeline.rs");
1097
1098        assert!(!runtime_source.contains("#[mol_op_body"));
1099        assert!(bodies_source.contains("#[mol_op_body"));
1100        assert!(hydrogens_source.contains("#[mol_op_body"));
1101        assert!(sanitize_source.contains("#[mol_op_body"));
1102    }
1103
1104    #[cfg(feature = "op-contracts")]
1105    #[test]
1106    fn begin_topology_mut_rejects_second_begin() {
1107        let molecule = crate::Molecule::new();
1108        let mut parts = OpParts::new(&molecule, &WITH_KEKULIZED_BONDS_SPEC).unwrap();
1109        let _topology = parts
1110            .begin_topology_mut()
1111            .expect("first topology begin should succeed");
1112        let err = match parts.begin_topology_mut() {
1113            Ok(_) => panic!("second topology begin must be rejected"),
1114            Err(err) => err,
1115        };
1116        assert!(
1117            matches!(err, OperationError::InvalidInput { message, .. } if message.contains("same writable block twice"))
1118        );
1119    }
1120
1121    #[cfg(feature = "op-contracts")]
1122    #[test]
1123    fn begin_topology_mut_rejects_second_begin_before_commit() {
1124        let molecule = crate::Molecule::new();
1125        let mut parts = OpParts::new(&molecule, &WITH_KEKULIZED_BONDS_SPEC).unwrap();
1126        let _topology = parts
1127            .begin_topology_mut()
1128            .expect("first topology begin should succeed");
1129        let err = match parts.begin_topology_mut() {
1130            Ok(_) => panic!("second topology begin must be rejected"),
1131            Err(err) => err,
1132        };
1133        assert!(
1134            matches!(err, OperationError::InvalidInput { message, .. } if message.contains("same writable block twice"))
1135        );
1136    }
1137
1138    #[cfg(feature = "op-contracts")]
1139    #[test]
1140    fn begin_access_rejects_overlapping_read_and_write_blocks() {
1141        let molecule = crate::Molecule::new();
1142        let mut parts = OpParts::new(&molecule, &TEST_OVERLAPPING_ACCESS_SPEC).unwrap();
1143        let err = match parts.begin_topology_mut() {
1144            Ok(_) => panic!("overlapping read/write access must be rejected"),
1145            Err(err) => err,
1146        };
1147        assert!(
1148            matches!(err, OperationError::InvalidInput { message, .. } if message.contains("both read and write"))
1149        );
1150    }
1151
1152    #[test]
1153    fn sanitize_is_bond_order_query_matches_rdkit_simple_vs_complex_split() {
1154        assert!(sanitize_is_bond_order_query(Some(&QueryNode::predicate(
1155            BondQueryPredicate::Order(BondOrder::Single),
1156        ))));
1157        assert!(sanitize_is_bond_order_query(Some(&QueryNode::and(vec![
1158            QueryNode::predicate(BondQueryPredicate::IsInRing(true)),
1159            QueryNode::predicate(BondQueryPredicate::Order(BondOrder::Double)),
1160        ]))));
1161        assert!(!sanitize_is_bond_order_query(Some(&QueryNode::predicate(
1162            BondQueryPredicate::OrderIn(vec![BondOrder::Single, BondOrder::Double]),
1163        ))));
1164        assert!(!sanitize_is_bond_order_query(Some(&QueryNode::not(
1165            QueryNode::predicate(BondQueryPredicate::Order(BondOrder::Single)),
1166        ))));
1167        assert!(!sanitize_is_bond_order_query(Some(&QueryNode::predicate(
1168            BondQueryPredicate::Any,
1169        ))));
1170    }
1171
1172    fn same_operation(left: &'static MoleculeOpSpec, right: &'static MoleculeOpSpec) -> bool {
1173        left.method == right.method && left.impl_fn == right.impl_fn
1174    }
1175
1176    fn support_matrix_contains(operation: &'static MoleculeOpSpec) -> bool {
1177        SUPPORT_MATRIX.iter().any(|entry| {
1178            entry
1179                .operation
1180                .is_some_and(|candidate| same_operation(candidate, operation))
1181        })
1182    }
1183
1184    fn support_feature_for(
1185        operation: &'static MoleculeOpSpec,
1186    ) -> Option<&'static crate::FeatureSpec> {
1187        SUPPORT_MATRIX.iter().find_map(|entry| {
1188            entry
1189                .operation
1190                .is_some_and(|candidate| same_operation(candidate, operation))
1191                .then_some(entry.feature)
1192        })
1193    }
1194
1195    fn invariant_matrix_contains(operation: &'static MoleculeOpSpec) -> bool {
1196        OPERATION_INVARIANT_MATRIX
1197            .iter()
1198            .any(|entry| same_operation(entry.operation, operation))
1199    }
1200
1201    fn parity_matrix_contains(operation: &'static MoleculeOpSpec) -> bool {
1202        PARITY_MATRIX
1203            .iter()
1204            .any(|entry| same_operation(entry.operation, operation))
1205    }
1206
1207    #[allow(dead_code)]
1208    fn assert_unsupported_feature(
1209        result: Result<crate::Molecule, OperationError>,
1210        operation: &'static MoleculeOpSpec,
1211        feature: &'static crate::FeatureSpec,
1212    ) {
1213        match result {
1214            Err(OperationError::UnsupportedFeature {
1215                operation: actual_operation,
1216                source,
1217            }) => {
1218                assert!(same_operation(actual_operation, operation));
1219                assert_eq!(source.feature, feature.name);
1220            }
1221            other => panic!(
1222                "expected UnsupportedFeature for {}, got {other:?}",
1223                operation.method
1224            ),
1225        }
1226    }
1227
1228    #[test]
1229    fn registered_ops_have_unique_methods() {
1230        let mut methods = HashSet::new();
1231        for operation in MOLECULE_OPS.iter().copied() {
1232            assert!(
1233                methods.insert(operation.method),
1234                "duplicate registered operation method `{}`",
1235                operation.method
1236            );
1237        }
1238    }
1239
1240    #[test]
1241    fn registered_ops_have_support_and_invariant_entries() {
1242        assert_eq!(SUPPORT_MATRIX.len(), MOLECULE_OPS.len());
1243        assert_eq!(OPERATION_INVARIANT_MATRIX.len(), MOLECULE_OPS.len());
1244        for operation in MOLECULE_OPS.iter().copied() {
1245            assert!(
1246                support_matrix_contains(operation),
1247                "missing support matrix entry for `{}`",
1248                operation.method
1249            );
1250            assert!(
1251                invariant_matrix_contains(operation),
1252                "missing invariant matrix entry for `{}`",
1253                operation.method
1254            );
1255        }
1256    }
1257
1258    #[test]
1259    fn parity_registered_ops_have_parity_entries() {
1260        for operation in MOLECULE_OPS.iter().copied() {
1261            if operation.parity != ParityPolicy::NotApplicable {
1262                assert!(
1263                    parity_matrix_contains(operation),
1264                    "missing parity matrix entry for `{}`",
1265                    operation.method
1266                );
1267            }
1268        }
1269    }
1270
1271    #[test]
1272    fn conformer_generation_operation_registry_exposes_3d_generation_entries() {
1273        assert!(
1274            MOLECULE_OPS
1275                .iter()
1276                .any(|operation| same_operation(operation, &WITH_3D_CONFORMER_SPEC))
1277        );
1278        assert!(
1279            MOLECULE_OPS
1280                .iter()
1281                .any(|operation| same_operation(operation, &WITH_3D_CONFORMERS_SPEC))
1282        );
1283        assert_eq!(
1284            support_feature_for(&WITH_3D_CONFORMER_SPEC).map(|feature| feature.name),
1285            Some(crate::CONFORMER_GENERATION_FEATURE.name)
1286        );
1287        assert_eq!(
1288            support_feature_for(&WITH_3D_CONFORMERS_SPEC).map(|feature| feature.name),
1289            Some(crate::CONFORMER_GENERATION_FEATURE.name)
1290        );
1291    }
1292
1293    #[test]
1294    fn coordinate_edit_operations_are_registered_as_operation_contracts() {
1295        assert!(
1296            MOLECULE_OPS
1297                .iter()
1298                .any(|operation| same_operation(operation, &WITH_2D_COORDINATE_BLOCK_SPEC))
1299        );
1300        assert!(
1301            MOLECULE_OPS
1302                .iter()
1303                .any(|operation| same_operation(operation, &WITH_3D_COORDINATES_SPEC))
1304        );
1305        assert!(
1306            MOLECULE_OPS
1307                .iter()
1308                .any(|operation| same_operation(operation, &WITH_CLEARED_3D_CONFORMERS_SPEC))
1309        );
1310        assert!(
1311            MOLECULE_OPS
1312                .iter()
1313                .any(|operation| same_operation(operation, &WITH_ADDED_3D_CONFORMER_SPEC))
1314        );
1315        assert!(
1316            MOLECULE_OPS
1317                .iter()
1318                .any(|operation| same_operation(operation, &WITH_ONLY_3D_CONFORMER_SPEC))
1319        );
1320        assert_eq!(
1321            support_feature_for(&WITH_2D_COORDINATE_BLOCK_SPEC).map(|feature| feature.name),
1322            Some(crate::COORDINATE_EDIT_FEATURE.name)
1323        );
1324        assert_eq!(
1325            support_feature_for(&WITH_3D_COORDINATES_SPEC).map(|feature| feature.name),
1326            Some(crate::COORDINATE_EDIT_FEATURE.name)
1327        );
1328        assert_eq!(
1329            support_feature_for(&WITH_CLEARED_3D_CONFORMERS_SPEC).map(|feature| feature.name),
1330            Some(crate::COORDINATE_EDIT_FEATURE.name)
1331        );
1332        assert_eq!(
1333            support_feature_for(&WITH_ADDED_3D_CONFORMER_SPEC).map(|feature| feature.name),
1334            Some(crate::COORDINATE_EDIT_FEATURE.name)
1335        );
1336        assert_eq!(
1337            support_feature_for(&WITH_ONLY_3D_CONFORMER_SPEC).map(|feature| feature.name),
1338            Some(crate::COORDINATE_EDIT_FEATURE.name)
1339        );
1340        assert_eq!(
1341            WITH_2D_COORDINATE_BLOCK_SPEC.domain,
1342            OperationDomain::Coordinate
1343        );
1344        assert_eq!(WITH_3D_COORDINATES_SPEC.domain, OperationDomain::Coordinate);
1345        assert_eq!(
1346            WITH_CLEARED_3D_CONFORMERS_SPEC.domain,
1347            OperationDomain::Coordinate
1348        );
1349        assert_eq!(
1350            WITH_ADDED_3D_CONFORMER_SPEC.domain,
1351            OperationDomain::Coordinate
1352        );
1353        assert_eq!(
1354            WITH_ONLY_3D_CONFORMER_SPEC.domain,
1355            OperationDomain::Coordinate
1356        );
1357        assert!(support_matrix_contains(&WITH_2D_COORDINATE_BLOCK_SPEC));
1358        assert!(support_matrix_contains(&WITH_3D_COORDINATES_SPEC));
1359        assert!(support_matrix_contains(&WITH_CLEARED_3D_CONFORMERS_SPEC));
1360        assert!(support_matrix_contains(&WITH_ADDED_3D_CONFORMER_SPEC));
1361        assert!(support_matrix_contains(&WITH_ONLY_3D_CONFORMER_SPEC));
1362        assert!(invariant_matrix_contains(&WITH_2D_COORDINATE_BLOCK_SPEC));
1363        assert!(invariant_matrix_contains(&WITH_3D_COORDINATES_SPEC));
1364        assert!(invariant_matrix_contains(&WITH_CLEARED_3D_CONFORMERS_SPEC));
1365        assert!(invariant_matrix_contains(&WITH_ADDED_3D_CONFORMER_SPEC));
1366        assert!(invariant_matrix_contains(&WITH_ONLY_3D_CONFORMER_SPEC));
1367        assert!(parity_matrix_contains(&WITH_2D_COORDINATE_BLOCK_SPEC));
1368        assert!(parity_matrix_contains(&WITH_3D_COORDINATES_SPEC));
1369        assert!(parity_matrix_contains(&WITH_CLEARED_3D_CONFORMERS_SPEC));
1370        assert!(parity_matrix_contains(&WITH_ADDED_3D_CONFORMER_SPEC));
1371        assert!(parity_matrix_contains(&WITH_ONLY_3D_CONFORMER_SPEC));
1372    }
1373
1374    #[test]
1375    fn conformer_generation_operation_registry_exposes_3d_coordinate_generation() {
1376        assert!(
1377            MOLECULE_OPS
1378                .iter()
1379                .any(|operation| same_operation(operation, &WITH_3D_CONFORMER_SPEC))
1380        );
1381        assert!(
1382            MOLECULE_OPS
1383                .iter()
1384                .any(|operation| same_operation(operation, &WITH_3D_CONFORMERS_SPEC))
1385        );
1386        assert_eq!(
1387            support_feature_for(&WITH_3D_CONFORMER_SPEC).map(|feature| feature.name),
1388            Some(crate::CONFORMER_GENERATION_FEATURE.name)
1389        );
1390        assert_eq!(
1391            support_feature_for(&WITH_3D_CONFORMERS_SPEC).map(|feature| feature.name),
1392            Some(crate::CONFORMER_GENERATION_FEATURE.name)
1393        );
1394        assert_eq!(WITH_3D_CONFORMER_SPEC.domain, OperationDomain::Coordinate);
1395        assert_eq!(WITH_3D_CONFORMERS_SPEC.domain, OperationDomain::Coordinate);
1396        assert!(support_matrix_contains(&WITH_3D_CONFORMER_SPEC));
1397        assert!(support_matrix_contains(&WITH_3D_CONFORMERS_SPEC));
1398        assert!(invariant_matrix_contains(&WITH_3D_CONFORMER_SPEC));
1399        assert!(invariant_matrix_contains(&WITH_3D_CONFORMERS_SPEC));
1400        assert!(parity_matrix_contains(&WITH_3D_CONFORMER_SPEC));
1401        assert!(parity_matrix_contains(&WITH_3D_CONFORMERS_SPEC));
1402
1403        let molecule = crate::Molecule::from_smiles("CC").expect("ethane");
1404        let generated = molecule
1405            .with_3d_conformer()
1406            .expect("default ETKDGv3 conformer");
1407        assert!(molecule.conformers_3d().is_empty());
1408        assert_eq!(generated.conformers_3d().len(), 1);
1409
1410        let mut params = crate::EmbedParameters::etkdg();
1411        params.random_seed = 0xf00d;
1412        params.num_threads = 1;
1413        let generated_multi = molecule
1414            .with_3d_conformers_with_params(2, params)
1415            .expect("multi conformer generation");
1416        assert_eq!(generated_multi.conformers_3d().len(), 2);
1417    }
1418
1419    #[test]
1420    fn sanitized_all_runs_through_operation_pipeline_without_changing_source() {
1421        let molecule = crate::Molecule::new();
1422        let original = molecule.clone();
1423
1424        let sanitized = molecule.sanitize().unwrap();
1425        let sanitized_with_all = molecule.sanitize_with_ops(crate::SanitizeOps::ALL).unwrap();
1426
1427        assert_eq!(sanitized.num_atoms(), 0);
1428        assert_eq!(sanitized_with_all.num_atoms(), 0);
1429        assert!(matches!(
1430            molecule.with_2d_coordinates(),
1431            Err(OperationError::InvalidInput {
1432                operation: &WITH_2D_COORDINATES_SPEC,
1433                ..
1434            })
1435        ));
1436
1437        assert_eq!(molecule, original);
1438    }
1439
1440    #[test]
1441    fn with_2d_coordinates_with_params_preserves_source_and_uses_parameterized_surface() {
1442        let mut builder = crate::MoleculeBuilder::new();
1443        let a0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1444        let a1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1445        builder
1446            .add_bond(crate::BondSpec::new(a0, a1, crate::BondOrder::Single))
1447            .unwrap();
1448        let molecule = builder.build().unwrap();
1449        let original = molecule.clone();
1450
1451        let result = molecule
1452            .with_2d_coordinates_with_params(crate::With2DCoordinatesParams {
1453                force_rdkit: true,
1454                use_ring_templates: true,
1455                ..crate::With2DCoordinatesParams::default()
1456            })
1457            .unwrap();
1458
1459        assert_eq!(molecule, original);
1460        assert_eq!(result.conformers_2d().len(), 1);
1461        assert_eq!(
1462            result.source_coordinate_dim(),
1463            Some(crate::CoordinateDimension::TwoD)
1464        );
1465    }
1466
1467    #[test]
1468    fn three_d_conformer_clear_and_single_assignment_run_through_coordinate_ops() {
1469        let mut builder = crate::MoleculeBuilder::new();
1470        let a0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1471        let a1 = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
1472        builder
1473            .add_bond(crate::BondSpec::new(a0, a1, crate::BondOrder::Single))
1474            .unwrap();
1475        let molecule = builder.build().unwrap();
1476        let first = vec![[0.0, 0.0, 0.0], [1.2, 0.0, 0.0]];
1477        let second = vec![[0.0, 0.0, 1.0], [1.2, 0.0, 1.0]];
1478        let replacement = vec![[0.5, 0.5, 0.5], [1.7, 0.5, 0.5]];
1479
1480        let with_two = molecule
1481            .with_added_3d_conformer(first.clone(), true)
1482            .unwrap()
1483            .with_added_3d_conformer(second.clone(), true)
1484            .unwrap();
1485        let cleared = with_two.with_cleared_3d_conformers().unwrap();
1486        let only = with_two
1487            .with_only_3d_conformer(replacement.clone(), true)
1488            .unwrap();
1489
1490        assert_eq!(with_two.conformers_3d().len(), 2);
1491        assert!(cleared.conformers_3d().is_empty());
1492        assert_eq!(only.conformers_3d().len(), 1);
1493        assert_eq!(only.conformers_3d()[0].coordinates(), replacement);
1494        assert_eq!(
1495            only.source_coordinate_dim(),
1496            Some(crate::CoordinateDimension::ThreeD)
1497        );
1498
1499        let mut in_place = with_two.clone();
1500        in_place.clear_3d_conformers_().unwrap();
1501        assert!(in_place.conformers_3d().is_empty());
1502        in_place
1503            .set_only_3d_conformer_(second.clone(), true)
1504            .unwrap();
1505        assert_eq!(in_place.conformers_3d().len(), 1);
1506        assert_eq!(in_place.conformers_3d()[0].coordinates(), second);
1507
1508        let two_d_flagged = with_two
1509            .with_only_3d_conformer(replacement.clone(), false)
1510            .unwrap();
1511        assert_eq!(
1512            two_d_flagged.source_coordinate_dim(),
1513            Some(crate::CoordinateDimension::TwoD)
1514        );
1515        assert!(!two_d_flagged.conformers_3d()[0].is_3d());
1516    }
1517
1518    #[test]
1519    fn with_hydrogens_adds_implicit_hydrogens_through_operation_pipeline() {
1520        let mut builder = crate::MoleculeBuilder::new();
1521        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1522        builder.set_2d_coordinates(vec![[0.5, 1.0]]).unwrap();
1523        let molecule = builder.build().unwrap();
1524        let original = molecule.clone();
1525
1526        let result = molecule.with_hydrogens().unwrap();
1527
1528        assert_eq!(molecule, original);
1529        assert_eq!(result.num_atoms(), 5);
1530        assert_eq!(result.num_bonds(), 4);
1531        assert_eq!(result.atoms()[carbon.index()].explicit_hydrogens(), 0);
1532        assert!(
1533            result.atoms()[1..]
1534                .iter()
1535                .all(|atom| atom.atomic_number() == 1 && atom.implicit_hydrogen())
1536        );
1537        assert_eq!(
1538            result.coordinates_2d(),
1539            Some(&[[0.5, 1.0], [0.5, 1.0], [0.5, 1.0], [0.5, 1.0], [0.5, 1.0]][..])
1540        );
1541    }
1542
1543    #[test]
1544    fn add_hydrogens_in_place_matches_value_operation() {
1545        let molecule = crate::Molecule::from_smiles("CCO").unwrap();
1546        let expected = molecule.with_hydrogens().unwrap();
1547
1548        let mut in_place = molecule.clone();
1549        in_place.add_hydrogens_().unwrap();
1550
1551        assert_eq!(in_place, expected);
1552    }
1553
1554    #[test]
1555    fn add_hydrogens_in_place_preserves_shared_source_value() {
1556        let mut molecule = crate::Molecule::from_smiles("CCO").unwrap();
1557        let shared = molecule.clone();
1558
1559        molecule.add_hydrogens_().unwrap();
1560
1561        assert_eq!(shared.num_atoms(), 3);
1562        assert_eq!(shared.num_bonds(), 2);
1563        assert_eq!(molecule.num_atoms(), 9);
1564        assert_eq!(molecule.num_bonds(), 8);
1565    }
1566
1567    #[test]
1568    fn add_hs_terminal_coords_follow_rdkit_sequential_append() {
1569        let mut builder = crate::MoleculeBuilder::new();
1570        builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1571        builder.set_2d_coordinates(vec![[0.0, 0.0]]).unwrap();
1572        let molecule = builder.build().unwrap();
1573        let params = crate::hydrogens::AddHsParams {
1574            add_coords: true,
1575            ..crate::hydrogens::AddHsParams::default()
1576        };
1577        let read_parts = MoleculeReadParts::from_molecule(&molecule);
1578        let assignment = crate::hydrogens::add_hs_assignment(read_parts, &params).unwrap();
1579
1580        let coords = add_hs_terminal_coords_2d(
1581            MoleculeReadParts::from_molecule(&molecule),
1582            &assignment,
1583            molecule.coordinates_2d().unwrap(),
1584        )
1585        .unwrap();
1586
1587        assert_eq!(coords.len(), 4);
1588        assert_eq!(coords[0], [1.0, 0.0]);
1589        assert_ne!(coords[1], [0.0, 0.0]);
1590    }
1591
1592    #[test]
1593    fn add_hs_terminal_coords_3d_uses_rdkit_rb0_bond_length() {
1594        let mut builder = crate::MoleculeBuilder::new();
1595        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1596        builder.add_3d_conformer(vec![[0.0, 0.0, 0.0]]).unwrap();
1597        let molecule = builder.build().unwrap();
1598        let assignment = crate::hydrogens::AddHsAssignment {
1599            hydrogens_to_add: vec![crate::hydrogens::AddHydrogen {
1600                heavy_atom: carbon,
1601                isotope: None,
1602                is_implicit: true,
1603                props: Default::default(),
1604                pdb_residue_info: None,
1605            }],
1606            add_terminal_coordinates: true,
1607            ..crate::hydrogens::AddHsAssignment::default()
1608        };
1609
1610        let coords = add_hs_terminal_coords_3d(
1611            MoleculeReadParts::from_molecule(&molecule),
1612            &assignment,
1613            molecule.conformers_3d()[0].coordinates(),
1614        )
1615        .unwrap();
1616
1617        assert_eq!(coords.len(), 1);
1618        assert!((coords[0][0] - 0.0).abs() < 1.0e-12);
1619        assert!((coords[0][1] - 0.0).abs() < 1.0e-12);
1620        assert!((coords[0][2] - 1.10).abs() < 1.0e-12);
1621    }
1622
1623    #[test]
1624    fn add_hs_terminal_coords_default_degree_branch_matches_rdkit_zero_vector() {
1625        let mut builder = crate::MoleculeBuilder::new();
1626        let center = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1627        let mut coords = vec![[5.0, 5.0, 5.0]];
1628        for index in 0..5 {
1629            let neighbor = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1630            builder
1631                .add_bond(crate::BondSpec::new(
1632                    center,
1633                    neighbor,
1634                    crate::BondOrder::Single,
1635                ))
1636                .unwrap();
1637            coords.push([index as f64, 0.0, 0.0]);
1638        }
1639        builder.add_3d_conformer(coords).unwrap();
1640        let molecule = builder.build().unwrap();
1641        let assignment = crate::hydrogens::AddHsAssignment {
1642            hydrogens_to_add: vec![crate::hydrogens::AddHydrogen {
1643                heavy_atom: center,
1644                isotope: None,
1645                is_implicit: true,
1646                props: Default::default(),
1647                pdb_residue_info: None,
1648            }],
1649            add_terminal_coordinates: true,
1650            ..crate::hydrogens::AddHsAssignment::default()
1651        };
1652
1653        let coords = add_hs_terminal_coords_3d(
1654            MoleculeReadParts::from_molecule(&molecule),
1655            &assignment,
1656            molecule.conformers_3d()[0].coordinates(),
1657        )
1658        .unwrap();
1659
1660        assert_eq!(coords, vec![[0.0, 0.0, 0.0]]);
1661    }
1662
1663    #[test]
1664    fn add_hs_terminal_coords_rejects_degenerate_or_nonterminal_virtual_atom_like_rdkit() {
1665        let mut builder = crate::MoleculeBuilder::new();
1666        let a0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1667        let a1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1668        builder
1669            .add_bond(crate::BondSpec::new(a0, a1, crate::BondOrder::Single))
1670            .unwrap();
1671        builder
1672            .add_3d_conformer(vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]])
1673            .unwrap();
1674        let molecule = builder.build().unwrap();
1675        let adjacency = vec![vec![(1, None), (2, None)], vec![(0, None)], vec![(0, None)]];
1676
1677        let degenerate = add_hs_set_terminal_atom_coord(
1678            MoleculeReadParts::from_molecule(&molecule),
1679            &adjacency,
1680            molecule.conformers_3d()[0].coordinates(),
1681            0,
1682            0,
1683            true,
1684        )
1685        .unwrap_err();
1686        assert!(degenerate.to_string().contains("degenerate atoms"));
1687
1688        let nonterminal = add_hs_set_terminal_atom_coord(
1689            MoleculeReadParts::from_molecule(&molecule),
1690            &adjacency,
1691            molecule.conformers_3d()[0].coordinates(),
1692            0,
1693            1,
1694            true,
1695        )
1696        .unwrap_err();
1697        assert!(nonterminal.to_string().contains("degree one"));
1698    }
1699
1700    #[test]
1701    fn with_hydrogens_with_params_materializes_add_coords_branch() {
1702        let mut builder = crate::MoleculeBuilder::new();
1703        builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1704        builder.set_2d_coordinates(vec![[0.0, 0.0]]).unwrap();
1705        let molecule = builder.build().unwrap();
1706        let params = crate::AddHsParams {
1707            add_coords: true,
1708            ..crate::AddHsParams::default()
1709        };
1710
1711        let result = molecule.with_hydrogens_with_params(params).unwrap();
1712
1713        assert_eq!(result.num_atoms(), 5);
1714        let coords = result.coordinates_2d().unwrap();
1715        assert_eq!(coords.len(), 5);
1716        assert_eq!(coords[0], [0.0, 0.0]);
1717        assert_eq!(coords[1], [1.0, 0.0]);
1718        assert_eq!(coords[2], [-1.0, 0.0]);
1719    }
1720
1721    #[test]
1722    fn with_hydrogens_materializes_explicit_h_count_and_clears_heavy_atom_count() {
1723        let mut builder = crate::MoleculeBuilder::new();
1724        let nitrogen =
1725            builder.add_atom(crate::AtomSpec::new(crate::Element::N).with_explicit_hydrogens(2));
1726        let molecule = builder.build().unwrap();
1727
1728        let result = molecule.with_hydrogens().unwrap();
1729
1730        assert_eq!(result.num_atoms(), 4);
1731        assert_eq!(result.atoms()[nitrogen.index()].explicit_hydrogens(), 0);
1732        assert_eq!(
1733            result.atoms()[1..]
1734                .iter()
1735                .filter(|atom| !atom.implicit_hydrogen())
1736                .count(),
1737            2
1738        );
1739        assert_eq!(
1740            result.atoms()[1..]
1741                .iter()
1742                .filter(|atom| atom.implicit_hydrogen())
1743                .count(),
1744            1
1745        );
1746    }
1747
1748    #[test]
1749    fn with_hydrogens_commits_topology_with_rebuilt_adjacency_for_valence_followups() {
1750        let molecule = crate::Molecule::from_smiles("C=C").unwrap();
1751
1752        let result = molecule.with_hydrogens().unwrap();
1753        let assignment = crate::assign_valence(&result, crate::ValenceModel::RdkitLike).unwrap();
1754
1755        assert_eq!(assignment.explicit_valence, vec![4, 4, 1, 1, 1, 1]);
1756        assert_eq!(assignment.implicit_hydrogens, vec![0, 0, 0, 0, 0, 0]);
1757    }
1758
1759    #[test]
1760    fn with_hydrogens_replays_tracked_isotopes_and_clears_tracking_property() {
1761        let mut builder = crate::MoleculeBuilder::new();
1762        let nitrogen = builder.add_atom(
1763            crate::AtomSpec::new(crate::Element::N)
1764                .with_explicit_hydrogens(2)
1765                .with_tracked_isotopic_hydrogens(vec![2, 3]),
1766        );
1767        let molecule = builder.build().unwrap();
1768
1769        let result = molecule.with_hydrogens().unwrap();
1770
1771        assert_eq!(result.atoms()[nitrogen.index()].prop("_isotopicHs"), None);
1772        assert_eq!(
1773            result.atoms()[nitrogen.index()].tracked_isotopic_hydrogens(),
1774            &[] as &[u16]
1775        );
1776        assert_eq!(result.atoms()[nitrogen.index()].explicit_hydrogens(), 0);
1777        assert_eq!(
1778            result.atoms()[1..]
1779                .iter()
1780                .map(crate::Atom::isotope)
1781                .collect::<Vec<_>>(),
1782            vec![Some(2), Some(3), None]
1783        );
1784    }
1785
1786    #[test]
1787    fn with_hydrogens_clears_atom_cip_ranks_like_rdkit_addhs() {
1788        let smiles =
1789            "O=C(NC[C@]12C[C@H]3C[C@H](C[C@H](C3)C1)C2)[C@@H]1C[C@H]2c3ccccc3[C@@H]1c1ccccc12";
1790        let molecule = crate::Molecule::from_smiles(smiles).unwrap();
1791        assert!(
1792            molecule
1793                .atoms()
1794                .iter()
1795                .any(|atom| atom.prop("_CIPRank").is_some()),
1796            "SMILES sanitize path should assign legacy _CIPRank before AddHs"
1797        );
1798
1799        let result = molecule
1800            .with_hydrogens_with_params(crate::AddHsParams {
1801                only_on_atoms: Some(
1802                    [4usize, 6, 8, 10, 14, 16, 23]
1803                        .into_iter()
1804                        .map(crate::AtomId::new)
1805                        .collect(),
1806                ),
1807                ..crate::AddHsParams::default()
1808            })
1809            .unwrap();
1810
1811        assert!(
1812            result
1813                .atoms()
1814                .iter()
1815                .all(|atom| atom.prop("_CIPRank").is_none()),
1816            "RDKit AddHs clears atom _CIPRank computed props before depiction"
1817        );
1818    }
1819
1820    #[test]
1821    fn add_hs_operation_materializes_typed_pdb_residue_info() {
1822        let mut builder = crate::MoleculeBuilder::new();
1823        let nitrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
1824        let molecule = builder.build().unwrap();
1825        let assignment = crate::hydrogens::AddHsAssignment {
1826            hydrogens_to_add: vec![crate::hydrogens::AddHydrogen {
1827                heavy_atom: nitrogen,
1828                isotope: None,
1829                is_implicit: false,
1830                props: Default::default(),
1831                pdb_residue_info: Some(crate::AtomPdbResidueInfo::new(
1832                    " H1 ", 12, "GLY", 3, "A", false,
1833                )),
1834            }],
1835            ..crate::hydrogens::AddHsAssignment::default()
1836        };
1837        let mut parts = OpParts::new(&molecule, &WITH_HYDROGENS_SPEC).unwrap();
1838        let mut topology = parts.begin_topology_mut().unwrap();
1839        let mut coordinates = parts.begin_coordinates_mut().unwrap();
1840        let mut properties = parts.begin_properties_mut().unwrap();
1841
1842        let changed = apply_add_hs_assignment(
1843            &mut parts,
1844            &mut topology,
1845            &mut coordinates,
1846            &mut properties,
1847            &assignment,
1848        )
1849        .unwrap();
1850        parts.commit_topology(topology).unwrap();
1851        parts.commit_coordinates(coordinates).unwrap();
1852        parts.commit_properties(properties).unwrap();
1853        parts
1854            .prove_preserved(
1855                DerivedState::RINGS | DerivedState::RING_FAMILIES,
1856                PreservationProof::LeafAtomAppend,
1857            )
1858            .unwrap();
1859        let result = parts.finish(OpOutcome::Changed).unwrap();
1860
1861        assert!(changed);
1862        let info = result.atoms()[1].pdb_residue_info().unwrap();
1863        assert_eq!(info.atom_name(), " H1 ");
1864        assert_eq!(info.serial_number(), 12);
1865        assert_eq!(info.residue_name(), "GLY");
1866        assert_eq!(info.residue_number(), 3);
1867        assert_eq!(info.chain_id(), "A");
1868    }
1869
1870    #[test]
1871    fn with_hydrogens_with_params_materializes_add_residue_info_branch() {
1872        let mut builder = crate::MoleculeBuilder::new();
1873        builder.add_atom(
1874            crate::AtomSpec::new(crate::Element::N).with_pdb_residue_info(
1875                crate::AtomPdbResidueInfo::new(" N  ", 10, "GLY", 3, "A", false),
1876            ),
1877        );
1878        let molecule = builder.build().unwrap();
1879        let params = crate::AddHsParams {
1880            add_residue_info: true,
1881            ..crate::AddHsParams::default()
1882        };
1883
1884        let result = molecule.with_hydrogens_with_params(params).unwrap();
1885
1886        assert_eq!(result.num_atoms(), 4);
1887        let first_h_info = result.atoms()[1].pdb_residue_info().unwrap();
1888        assert_eq!(first_h_info.atom_name(), " H1 ");
1889        assert_eq!(first_h_info.residue_name(), "GLY");
1890        assert_eq!(first_h_info.residue_number(), 3);
1891        assert_eq!(first_h_info.chain_id(), "A");
1892    }
1893
1894    #[test]
1895    fn add_hs_operation_materializes_existing_atom_pdb_residue_info_updates() {
1896        let mut builder = crate::MoleculeBuilder::new();
1897        let hydrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::H));
1898        let molecule = builder.build().unwrap();
1899        let assignment = crate::hydrogens::AddHsAssignment {
1900            atom_pdb_residue_info_updates: vec![crate::hydrogens::AtomPdbResidueInfoUpdate {
1901                atom: hydrogen,
1902                pdb_residue_info: crate::AtomPdbResidueInfo::new(" H1 ", 12, "GLY", 3, "A", false),
1903            }],
1904            ..crate::hydrogens::AddHsAssignment::default()
1905        };
1906        let mut parts = OpParts::new(&molecule, &WITH_HYDROGENS_SPEC).unwrap();
1907        let mut topology = parts.begin_topology_mut().unwrap();
1908        let mut coordinates = parts.begin_coordinates_mut().unwrap();
1909        let mut properties = parts.begin_properties_mut().unwrap();
1910
1911        let changed = apply_add_hs_assignment(
1912            &mut parts,
1913            &mut topology,
1914            &mut coordinates,
1915            &mut properties,
1916            &assignment,
1917        )
1918        .unwrap();
1919        parts.commit_topology(topology).unwrap();
1920        parts.commit_coordinates(coordinates).unwrap();
1921        parts.commit_properties(properties).unwrap();
1922        parts
1923            .prove_preserved(
1924                DerivedState::RINGS | DerivedState::RING_FAMILIES,
1925                PreservationProof::LeafAtomAppend,
1926            )
1927            .unwrap();
1928        let result = parts.finish(OpOutcome::Changed).unwrap();
1929
1930        assert!(changed);
1931        let info = result.atoms()[hydrogen.index()].pdb_residue_info().unwrap();
1932        assert_eq!(info.atom_name(), " H1 ");
1933        assert_eq!(info.serial_number(), 12);
1934        assert_eq!(info.residue_name(), "GLY");
1935        assert_eq!(info.residue_number(), 3);
1936        assert_eq!(info.chain_id(), "A");
1937    }
1938
1939    #[test]
1940    fn without_hydrogens_removes_basic_explicit_hydrogen_through_operation_pipeline() {
1941        let mut builder = crate::MoleculeBuilder::new();
1942        let carbon = builder.add_atom(
1943            crate::AtomSpec::new(crate::Element::C).with_pdb_residue_info(
1944                crate::AtomPdbResidueInfo::new(" C  ", 7, "GLY", 3, "A", false),
1945            ),
1946        );
1947        let hydrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::H));
1948        builder
1949            .add_bond(crate::BondSpec::new(
1950                carbon,
1951                hydrogen,
1952                crate::BondOrder::Single,
1953            ))
1954            .unwrap();
1955        builder
1956            .set_2d_coordinates(vec![[0.0, 0.0], [1.0, 0.0]])
1957            .unwrap();
1958        let molecule = builder.build().unwrap();
1959        let original = molecule.clone();
1960
1961        let result = molecule.without_hydrogens_with_sanitize(false).unwrap();
1962
1963        assert_eq!(molecule, original);
1964        assert_eq!(result.num_atoms(), 1);
1965        assert_eq!(result.num_bonds(), 0);
1966        assert_eq!(result.coordinates_2d(), Some(&[[0.0, 0.0]][..]));
1967        assert_eq!(
1968            result.atoms()[0]
1969                .pdb_residue_info()
1970                .unwrap()
1971                .serial_number(),
1972            7
1973        );
1974    }
1975
1976    #[test]
1977    fn without_hydrogens_materializes_unknown_stereo_as_typed_atom_state() {
1978        let mut builder = crate::MoleculeBuilder::new();
1979        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
1980        let hydrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::H));
1981        builder
1982            .add_bond(
1983                crate::BondSpec::new(carbon, hydrogen, crate::BondOrder::Single)
1984                    .with_direction(crate::BondDirection::Unknown),
1985            )
1986            .unwrap();
1987        let molecule = builder.build().unwrap();
1988
1989        let result = molecule.without_hydrogens_with_sanitize(false).unwrap();
1990
1991        assert_eq!(result.num_atoms(), 1);
1992        assert!(result.atoms()[0].unknown_stereo());
1993        assert_eq!(result.atoms()[0].prop("_UnknownStereo"), None);
1994    }
1995
1996    #[test]
1997    fn without_hydrogens_with_params_materializes_remove_and_track_isotopes_branch() {
1998        let mut builder = crate::MoleculeBuilder::new();
1999        let carbon =
2000            builder.add_atom(crate::AtomSpec::new(crate::Element::C).with_no_implicit(true));
2001        let protium = builder.add_atom(crate::AtomSpec::new(crate::Element::H));
2002        let deuterium = builder.add_atom(crate::AtomSpec::new(crate::Element::H).with_isotope(2));
2003        builder
2004            .add_bond(crate::BondSpec::new(
2005                carbon,
2006                protium,
2007                crate::BondOrder::Single,
2008            ))
2009            .unwrap();
2010        builder
2011            .add_bond(crate::BondSpec::new(
2012                carbon,
2013                deuterium,
2014                crate::BondOrder::Single,
2015            ))
2016            .unwrap();
2017        let molecule = builder.build().unwrap();
2018        let params = crate::RemoveHsParams {
2019            remove_and_track_isotopes: true,
2020            ..crate::RemoveHsParams::default()
2021        };
2022
2023        let result = molecule
2024            .without_hydrogens_with_params(params, false)
2025            .unwrap();
2026
2027        assert_eq!(result.num_atoms(), 1);
2028        assert_eq!(result.atoms()[0].tracked_isotopic_hydrogens(), &[2]);
2029        assert_eq!(result.atoms()[0].prop("_isotopicHs"), None);
2030    }
2031
2032    #[test]
2033    fn without_hydrogens_updates_sgroup_membership_before_topology_compaction() {
2034        let mut builder = crate::MoleculeBuilder::new();
2035        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2036        let hydrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::H));
2037        builder
2038            .add_bond(crate::BondSpec::new(
2039                carbon,
2040                hydrogen,
2041                crate::BondOrder::Single,
2042            ))
2043            .unwrap();
2044        builder
2045            .add_substance_group(
2046                crate::SubstanceGroup::new(
2047                    crate::SubstanceGroupId::new(0),
2048                    crate::SubstanceGroupKind::Superatom,
2049                )
2050                .with_atoms(vec![carbon, hydrogen]),
2051            )
2052            .unwrap();
2053        let molecule = builder.build().unwrap();
2054
2055        let result = molecule.without_hydrogens_with_sanitize(false).unwrap();
2056
2057        assert_eq!(result.num_atoms(), 1);
2058        assert_eq!(result.substance_groups().len(), 1);
2059        assert_eq!(
2060            result.substance_groups()[0].atoms(),
2061            &[crate::AtomId::new(0)]
2062        );
2063    }
2064
2065    #[test]
2066    fn without_hydrogens_with_sanitize_runs_full_sanitize_on_aromatic_result() {
2067        let molecule = crate::Molecule::from_smiles_with_sanitize("c1ccccc1", false)
2068            .unwrap()
2069            .with_hydrogens()
2070            .unwrap();
2071        let original = molecule.clone();
2072
2073        let result = molecule.without_hydrogens_with_sanitize(true).unwrap();
2074
2075        assert_eq!(molecule, original);
2076        assert_eq!(result.num_atoms(), 6);
2077        assert!(result.atoms().iter().all(crate::Atom::is_aromatic));
2078        assert!(
2079            result
2080                .bonds()
2081                .iter()
2082                .all(|bond| bond.is_aromatic() && bond.order() == crate::BondOrder::Aromatic)
2083        );
2084        assert!(result.derived_cache().valence.is_some());
2085        assert!(result.derived_cache().rings.is_some());
2086    }
2087
2088    #[test]
2089    fn without_hydrogens_without_sanitize_skips_full_sanitize_pipeline() {
2090        let molecule = crate::Molecule::from_smiles_with_sanitize("c1ccccc1", false)
2091            .unwrap()
2092            .with_hydrogens()
2093            .unwrap();
2094
2095        let result = molecule.without_hydrogens_with_sanitize(false).unwrap();
2096
2097        assert_eq!(result.num_atoms(), 6);
2098        assert!(result.derived_cache().valence.is_none());
2099        assert!(result.derived_cache().rings.is_none());
2100    }
2101
2102    #[test]
2103    fn with_hydrogens_preserves_existing_ring_caches_by_leaf_append_proof() {
2104        let molecule = crate::Molecule::from_smiles_with_sanitize("C1CCCCC1", false)
2105            .unwrap()
2106            .with_assigned_rings()
2107            .unwrap()
2108            .with_assigned_ring_families()
2109            .unwrap();
2110        let rings_before = molecule.derived_cache().rings.clone();
2111        let ring_families_before = molecule.derived_cache().ring_families.clone();
2112
2113        let result = molecule.with_hydrogens().unwrap();
2114
2115        assert_eq!(result.derived_cache().rings, rings_before);
2116        assert_eq!(result.derived_cache().ring_families, ring_families_before);
2117        assert!(result.derived_cache().valence.is_none());
2118    }
2119
2120    #[test]
2121    fn sanitized_supported_subset_updates_cache_through_operation_pipeline() {
2122        let molecule = crate::Molecule::from_smiles_with_sanitize("CCO", false).unwrap();
2123        let original = molecule.clone();
2124        let ops = crate::SanitizeOps::CLEANUP
2125            | crate::SanitizeOps::PROPERTIES
2126            | crate::SanitizeOps::SYMMRINGS
2127            | crate::SanitizeOps::FIND_RADICALS
2128            | crate::SanitizeOps::SET_AROMATICITY
2129            | crate::SanitizeOps::SET_CONJUGATION;
2130
2131        let result = molecule.sanitize_with_ops(ops).unwrap();
2132
2133        assert_eq!(molecule, original);
2134        let cache = result.derived_cache();
2135        assert!(cache.valence.is_some());
2136        assert!(cache.rings.is_some());
2137        assert!(cache.ring_families.is_none());
2138        assert!(cache.aromaticity_valid);
2139    }
2140
2141    #[test]
2142    fn sanitized_set_aromaticity_recomputes_valence_after_aromatic_bond_updates() {
2143        let molecule = crate::Molecule::from_smiles_with_sanitize("C1=CC=CC=C1", false).unwrap();
2144        let result = molecule
2145            .sanitize_with_ops(
2146                crate::SanitizeOps::PROPERTIES
2147                    | crate::SanitizeOps::SYMMRINGS
2148                    | crate::SanitizeOps::SET_AROMATICITY,
2149            )
2150            .unwrap();
2151
2152        assert!(
2153            result
2154                .bonds()
2155                .iter()
2156                .all(|bond| bond.order() == crate::BondOrder::Aromatic)
2157        );
2158        let expected_valence =
2159            crate::assign_valence_with_options(&result, crate::ValenceModel::RdkitLike, true)
2160                .unwrap();
2161        assert_eq!(result.derived_cache().valence, Some(expected_valence));
2162    }
2163
2164    #[test]
2165    fn sanitized_kekulize_runs_kekulize_assignment_like_rdkit() {
2166        let molecule = crate::Molecule::from_smiles_with_sanitize("c1ccccc1", false).unwrap();
2167
2168        let result = molecule
2169            .sanitize_with_ops(crate::SanitizeOps::SYMMRINGS | crate::SanitizeOps::KEKULIZE)
2170            .unwrap();
2171
2172        assert!(result.bonds().iter().all(|bond| !bond.is_aromatic()));
2173        assert!(result.bonds().iter().all(|bond| matches!(
2174            bond.order(),
2175            crate::BondOrder::Single | crate::BondOrder::Double
2176        )));
2177        assert_eq!(
2178            result
2179                .bonds()
2180                .iter()
2181                .filter(|bond| bond.order() == crate::BondOrder::Double)
2182                .count(),
2183            3
2184        );
2185        assert!(result.derived_cache().rings.is_some());
2186    }
2187
2188    #[test]
2189    fn sanitized_kekulize_materializes_ring_cache_without_explicit_symmrings_step_like_rdkit() {
2190        let molecule = crate::Molecule::from_smiles_with_sanitize("c1ccccc1", false).unwrap();
2191
2192        let result = molecule
2193            .sanitize_with_ops(crate::SanitizeOps::KEKULIZE)
2194            .unwrap();
2195
2196        assert!(result.derived_cache().rings.is_some());
2197        assert!(result.bonds().iter().all(|bond| matches!(
2198            bond.order(),
2199            crate::BondOrder::Single | crate::BondOrder::Double
2200        )));
2201    }
2202
2203    #[test]
2204    fn sanitized_reports_kekulize_failure_step_like_rdkit_operation_that_failed() {
2205        let molecule = crate::Molecule::from_smiles_with_sanitize("c", false).unwrap();
2206
2207        let err = molecule
2208            .sanitize_with_ops(crate::SanitizeOps::KEKULIZE)
2209            .unwrap_err();
2210
2211        match err {
2212            OperationError::Sanitize { source, .. } => {
2213                assert_eq!(source.step, crate::SanitizeStep::Kekulize);
2214                assert!(source.message.contains("aromatic"));
2215            }
2216            other => panic!("expected sanitize error, got {other:?}"),
2217        }
2218    }
2219
2220    #[test]
2221    fn sanitized_reports_properties_before_later_requested_steps() {
2222        let molecule = crate::Molecule::from_smiles_with_sanitize("C(=O)(=O)(=O)", false).unwrap();
2223
2224        let err = molecule
2225            .sanitize_with_ops(crate::SanitizeOps::PROPERTIES | crate::SanitizeOps::KEKULIZE)
2226            .unwrap_err();
2227
2228        match err {
2229            OperationError::Sanitize { source, .. } => {
2230                assert_eq!(source.step, crate::SanitizeStep::Properties);
2231                assert!(source.message.contains("greater than permitted"));
2232            }
2233            other => panic!("expected sanitize error, got {other:?}"),
2234        }
2235    }
2236
2237    #[test]
2238    fn sanitized_reports_properties_before_multiple_later_requested_steps() {
2239        let molecule = crate::Molecule::from_smiles_with_sanitize("C(=O)(=O)(=O)", false).unwrap();
2240
2241        let err = molecule
2242            .sanitize_with_ops(
2243                crate::SanitizeOps::PROPERTIES
2244                    | crate::SanitizeOps::KEKULIZE
2245                    | crate::SanitizeOps::SET_AROMATICITY
2246                    | crate::SanitizeOps::SET_CONJUGATION,
2247            )
2248            .unwrap_err();
2249
2250        match err {
2251            OperationError::Sanitize { source, .. } => {
2252                assert_eq!(source.step, crate::SanitizeStep::Properties);
2253                assert!(source.message.contains("greater than permitted"));
2254            }
2255            other => panic!("expected sanitize error, got {other:?}"),
2256        }
2257    }
2258
2259    #[test]
2260    fn sanitize_stage_maps_requested_step_through_shared_helper() {
2261        let err = sanitize_stage(
2262            crate::SanitizeStep::Cleanup,
2263            || -> Result<(), crate::ValenceError> {
2264                Err(crate::ValenceError::UnsupportedBranch {
2265                    reason: "helper step mapping regression",
2266                })
2267            },
2268            |step, source| sanitize_valence_error(&SANITIZE_SPEC, step, source),
2269        )
2270        .unwrap_err();
2271
2272        match err {
2273            OperationError::Sanitize { source, .. } => {
2274                assert_eq!(source.step, crate::SanitizeStep::Cleanup);
2275                assert!(source.message.contains("helper step mapping regression"));
2276            }
2277            other => panic!("expected sanitize error, got {other:?}"),
2278        }
2279    }
2280
2281    #[test]
2282    fn sanitized_without_properties_uses_non_strict_property_cache_like_rdkit() {
2283        let molecule = crate::Molecule::from_smiles_with_sanitize("C(=O)(=O)(=O)", false).unwrap();
2284
2285        let result = molecule
2286            .sanitize_with_ops(crate::SanitizeOps::NONE)
2287            .unwrap();
2288        let expected =
2289            crate::assign_valence_with_options(&result, crate::ValenceModel::RdkitLike, false)
2290                .unwrap();
2291
2292        assert_eq!(result.derived_cache().valence, Some(expected));
2293    }
2294
2295    #[test]
2296    fn sanitized_cleanup_converts_neutral_nitro_like_rdkit() {
2297        let mut builder = crate::MoleculeBuilder::new();
2298        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2299        let nitrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2300        let oxygen_single = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2301        let oxygen_double = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2302        builder
2303            .add_bond(crate::BondSpec::new(
2304                carbon,
2305                nitrogen,
2306                crate::BondOrder::Single,
2307            ))
2308            .unwrap();
2309        builder
2310            .add_bond(crate::BondSpec::new(
2311                nitrogen,
2312                oxygen_single,
2313                crate::BondOrder::Double,
2314            ))
2315            .unwrap();
2316        builder
2317            .add_bond(crate::BondSpec::new(
2318                nitrogen,
2319                oxygen_double,
2320                crate::BondOrder::Double,
2321            ))
2322            .unwrap();
2323        let molecule = builder.build().unwrap();
2324
2325        let result = molecule
2326            .sanitize_with_ops(crate::SanitizeOps::CLEANUP)
2327            .unwrap();
2328
2329        assert_eq!(result.atoms()[nitrogen.index()].formal_charge(), 1);
2330        assert_eq!(
2331            result
2332                .atoms()
2333                .iter()
2334                .filter(|atom| atom.atomic_number() == 8 && atom.formal_charge() == -1)
2335                .count(),
2336            1
2337        );
2338        assert_eq!(
2339            result
2340                .bonds()
2341                .iter()
2342                .filter(|bond| bond.order() == crate::BondOrder::Double)
2343                .count(),
2344            1
2345        );
2346    }
2347
2348    #[test]
2349    fn sanitized_nitrogens_cleanup_rewrites_neutral_nitrogen_triple_bond_branch_like_rdkit() {
2350        let mut builder = crate::MoleculeBuilder::new();
2351        let carbon_one = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2352        let nitrogen_center = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2353        let carbon_two = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2354        let nitrogen_terminal = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2355        builder
2356            .add_bond(crate::BondSpec::new(
2357                carbon_one,
2358                nitrogen_center,
2359                crate::BondOrder::Single,
2360            ))
2361            .unwrap();
2362        builder
2363            .add_bond(crate::BondSpec::new(
2364                nitrogen_center,
2365                carbon_two,
2366                crate::BondOrder::Single,
2367            ))
2368            .unwrap();
2369        let triple_bond = builder
2370            .add_bond(crate::BondSpec::new(
2371                nitrogen_center,
2372                nitrogen_terminal,
2373                crate::BondOrder::Triple,
2374            ))
2375            .unwrap();
2376        let molecule = builder.build().unwrap();
2377
2378        let result = molecule
2379            .sanitize_with_ops(crate::SanitizeOps::CLEANUP)
2380            .unwrap();
2381
2382        assert_eq!(result.atoms()[nitrogen_center.index()].formal_charge(), 1);
2383        assert_eq!(
2384            result.atoms()[nitrogen_terminal.index()].formal_charge(),
2385            -1
2386        );
2387        assert_eq!(
2388            result.bonds()[triple_bond.index()].order(),
2389            crate::BondOrder::Double
2390        );
2391    }
2392
2393    #[test]
2394    fn sanitized_cleanup_converts_phosphorus_oxo_like_rdkit() {
2395        let phosphorus_element =
2396            crate::Element::from_atomic_number(15).expect("phosphorus atomic number is valid");
2397        let mut builder = crate::MoleculeBuilder::new();
2398        let carbon_single = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2399        let phosphorus = builder.add_atom(crate::AtomSpec::new(phosphorus_element));
2400        let oxygen = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2401        let nitrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2402        let carbon_double = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2403        builder
2404            .add_bond(crate::BondSpec::new(
2405                carbon_single,
2406                phosphorus,
2407                crate::BondOrder::Single,
2408            ))
2409            .unwrap();
2410        builder
2411            .add_bond(crate::BondSpec::new(
2412                phosphorus,
2413                oxygen,
2414                crate::BondOrder::Double,
2415            ))
2416            .unwrap();
2417        builder
2418            .add_bond(crate::BondSpec::new(
2419                phosphorus,
2420                nitrogen,
2421                crate::BondOrder::Double,
2422            ))
2423            .unwrap();
2424        builder
2425            .add_bond(crate::BondSpec::new(
2426                nitrogen,
2427                carbon_double,
2428                crate::BondOrder::Single,
2429            ))
2430            .unwrap();
2431        let molecule = builder.build().unwrap();
2432
2433        let result = molecule
2434            .sanitize_with_ops(crate::SanitizeOps::CLEANUP)
2435            .unwrap();
2436
2437        assert_eq!(result.atoms()[phosphorus.index()].formal_charge(), 1);
2438        assert_eq!(result.atoms()[oxygen.index()].formal_charge(), -1);
2439        assert_eq!(result.atoms()[nitrogen.index()].formal_charge(), 0);
2440        assert_eq!(result.bonds()[1].order(), crate::BondOrder::Single);
2441        assert_eq!(result.bonds()[2].order(), crate::BondOrder::Double);
2442    }
2443
2444    #[test]
2445    fn sanitized_phosphorus_cleanup_leaves_double_oxo_without_double_cn_branch_unchanged_like_rdkit()
2446     {
2447        let phosphorus_element =
2448            crate::Element::from_atomic_number(15).expect("phosphorus atomic number is valid");
2449        let mut builder = crate::MoleculeBuilder::new();
2450        let phosphorus = builder.add_atom(crate::AtomSpec::new(phosphorus_element));
2451        let oxygen_one = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2452        let oxygen_two = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2453        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2454        let bond_one = builder
2455            .add_bond(crate::BondSpec::new(
2456                phosphorus,
2457                oxygen_one,
2458                crate::BondOrder::Double,
2459            ))
2460            .unwrap();
2461        let bond_two = builder
2462            .add_bond(crate::BondSpec::new(
2463                phosphorus,
2464                oxygen_two,
2465                crate::BondOrder::Double,
2466            ))
2467            .unwrap();
2468        builder
2469            .add_bond(crate::BondSpec::new(
2470                phosphorus,
2471                carbon,
2472                crate::BondOrder::Single,
2473            ))
2474            .unwrap();
2475        let molecule = builder.build().unwrap();
2476
2477        let result = molecule
2478            .sanitize_with_ops(crate::SanitizeOps::CLEANUP)
2479            .unwrap();
2480
2481        assert_eq!(result.atoms()[phosphorus.index()].formal_charge(), 0);
2482        assert_eq!(result.atoms()[oxygen_one.index()].formal_charge(), 0);
2483        assert_eq!(result.atoms()[oxygen_two.index()].formal_charge(), 0);
2484        assert_eq!(
2485            result.bonds()[bond_one.index()].order(),
2486            crate::BondOrder::Double
2487        );
2488        assert_eq!(
2489            result.bonds()[bond_two.index()].order(),
2490            crate::BondOrder::Double
2491        );
2492    }
2493
2494    #[test]
2495    fn sanitized_cleanup_converts_hypervalent_halogen_oxo_like_rdkit() {
2496        let chlorine =
2497            crate::Element::from_atomic_number(17).expect("chlorine atomic number is valid");
2498        let mut builder = crate::MoleculeBuilder::new();
2499        let center = builder.add_atom(crate::AtomSpec::new(chlorine));
2500        let oxygen_one = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2501        let oxygen_two = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2502        builder
2503            .add_bond(crate::BondSpec::new(
2504                center,
2505                oxygen_one,
2506                crate::BondOrder::Double,
2507            ))
2508            .unwrap();
2509        builder
2510            .add_bond(crate::BondSpec::new(
2511                center,
2512                oxygen_two,
2513                crate::BondOrder::Single,
2514            ))
2515            .unwrap();
2516        let molecule = builder.build().unwrap();
2517
2518        let result = molecule
2519            .sanitize_with_ops(crate::SanitizeOps::CLEANUP)
2520            .unwrap();
2521
2522        assert_eq!(result.atoms()[center.index()].formal_charge(), 1);
2523        assert_eq!(result.atoms()[oxygen_one.index()].formal_charge(), -1);
2524        assert_eq!(result.atoms()[oxygen_two.index()].formal_charge(), 0);
2525        assert!(
2526            result
2527                .bonds()
2528                .iter()
2529                .all(|bond| bond.order() == crate::BondOrder::Single)
2530        );
2531    }
2532
2533    #[test]
2534    fn sanitized_halogen_cleanup_skips_non_oxo_neighbor_branch_like_rdkit() {
2535        let chlorine =
2536            crate::Element::from_atomic_number(17).expect("chlorine atomic number is valid");
2537        let mut builder = crate::MoleculeBuilder::new();
2538        let center = builder.add_atom(crate::AtomSpec::new(chlorine));
2539        let oxygen = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2540        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2541        let double_bond = builder
2542            .add_bond(crate::BondSpec::new(
2543                center,
2544                oxygen,
2545                crate::BondOrder::Double,
2546            ))
2547            .unwrap();
2548        builder
2549            .add_bond(crate::BondSpec::new(
2550                center,
2551                carbon,
2552                crate::BondOrder::Single,
2553            ))
2554            .unwrap();
2555        let molecule = builder.build().unwrap();
2556
2557        let result = molecule
2558            .sanitize_with_ops(crate::SanitizeOps::CLEANUP)
2559            .unwrap();
2560
2561        assert_eq!(result.atoms()[center.index()].formal_charge(), 0);
2562        assert_eq!(result.atoms()[oxygen.index()].formal_charge(), 0);
2563        assert_eq!(
2564            result.bonds()[double_bond.index()].order(),
2565            crate::BondOrder::Double
2566        );
2567    }
2568
2569    #[test]
2570    fn cleanup_incident_bonds_returns_only_local_bond_indices() {
2571        let mut builder = crate::MoleculeBuilder::new();
2572        let a0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2573        let a1 = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2574        let a2 = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2575        let a3 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2576        builder
2577            .add_bond(crate::BondSpec::new(a0, a1, crate::BondOrder::Single))
2578            .unwrap();
2579        let center_left = builder
2580            .add_bond(crate::BondSpec::new(a1, a2, crate::BondOrder::Double))
2581            .unwrap();
2582        let center_right = builder
2583            .add_bond(crate::BondSpec::new(a1, a3, crate::BondOrder::Single))
2584            .unwrap();
2585        let molecule = builder.build().unwrap();
2586        let read = MoleculeReadParts::from_molecule(&molecule);
2587        let adjacency = sanitize_adjacency(read).unwrap();
2588
2589        let incident = sanitize_cleanup_incident_bonds(&adjacency, a1);
2590
2591        assert_eq!(incident, vec![0, center_left.index(), center_right.index()]);
2592    }
2593
2594    #[test]
2595    fn cleanup_incident_bonds_explicit_valence_uses_assignment_bond_orders() {
2596        let mut builder = crate::MoleculeBuilder::new();
2597        let nitrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2598        let oxygen = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
2599        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2600        let oxygen_bond = builder
2601            .add_bond(crate::BondSpec::new(
2602                nitrogen,
2603                oxygen,
2604                crate::BondOrder::Double,
2605            ))
2606            .unwrap();
2607        builder
2608            .add_bond(crate::BondSpec::new(
2609                nitrogen,
2610                carbon,
2611                crate::BondOrder::Single,
2612            ))
2613            .unwrap();
2614        let molecule = builder.build().unwrap();
2615        let read = MoleculeReadParts::from_molecule(&molecule);
2616        let adjacency = sanitize_adjacency(read).unwrap();
2617        let mut assignment = SanitizeCleanupAssignment {
2618            atom_formal_charges: molecule
2619                .atoms()
2620                .iter()
2621                .map(crate::Atom::formal_charge)
2622                .collect(),
2623            bond_orders: molecule.bonds().iter().map(crate::Bond::order).collect(),
2624        };
2625        assignment.bond_orders[oxygen_bond.index()] = crate::BondOrder::Single;
2626
2627        let valence =
2628            sanitize_cleanup_explicit_valence(read, &adjacency, &assignment, nitrogen).unwrap();
2629
2630        assert_eq!(valence, 2);
2631    }
2632
2633    #[test]
2634    fn sanitized_organometallic_cleanup_converts_single_metal_bond_to_dative_like_rdkit() {
2635        let iron = crate::Element::from_atomic_number(26).expect("iron atomic number is valid");
2636        let mut builder = crate::MoleculeBuilder::new();
2637        let nitrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2638        let carbon_one = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2639        let carbon_two = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2640        let carbon_three = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2641        let metal = builder.add_atom(crate::AtomSpec::new(iron));
2642        for neighbor in [carbon_one, carbon_two, carbon_three, metal] {
2643            builder
2644                .add_bond(crate::BondSpec::new(
2645                    nitrogen,
2646                    neighbor,
2647                    crate::BondOrder::Single,
2648                ))
2649                .unwrap();
2650        }
2651        let molecule = builder.build().unwrap();
2652
2653        let result = molecule
2654            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_ORGANOMETALLICS)
2655            .unwrap();
2656        let metal_bond = result
2657            .bonds()
2658            .iter()
2659            .find(|bond| {
2660                (bond.begin() == nitrogen && bond.end() == metal)
2661                    || (bond.begin() == metal && bond.end() == nitrogen)
2662            })
2663            .unwrap();
2664
2665        assert_eq!(metal_bond.order(), crate::BondOrder::Dative);
2666        assert_eq!(metal_bond.begin(), nitrogen);
2667        assert_eq!(metal_bond.end(), metal);
2668    }
2669
2670    #[test]
2671    fn sanitized_organometallic_cleanup_prefers_metal_with_fewer_existing_dative_bonds() {
2672        let iron = crate::Element::from_atomic_number(26).expect("iron atomic number is valid");
2673        let mut builder = crate::MoleculeBuilder::new();
2674
2675        let donor = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2676        let donor_c1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2677        let donor_c2 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2678        let donor_c3 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2679        let metal_busy = builder.add_atom(crate::AtomSpec::new(iron));
2680        let metal_open = builder.add_atom(crate::AtomSpec::new(iron));
2681
2682        for neighbor in [donor_c1, donor_c2, donor_c3, metal_busy, metal_open] {
2683            builder
2684                .add_bond(crate::BondSpec::new(
2685                    donor,
2686                    neighbor,
2687                    crate::BondOrder::Single,
2688                ))
2689                .unwrap();
2690        }
2691
2692        let donor_busy = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2693        let busy_c1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2694        let busy_c2 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2695        let busy_c3 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2696        for neighbor in [busy_c1, busy_c2, busy_c3] {
2697            builder
2698                .add_bond(crate::BondSpec::new(
2699                    donor_busy,
2700                    neighbor,
2701                    crate::BondOrder::Single,
2702                ))
2703                .unwrap();
2704        }
2705        builder
2706            .add_bond(crate::BondSpec::new(
2707                donor_busy,
2708                metal_busy,
2709                crate::BondOrder::Dative,
2710            ))
2711            .unwrap();
2712
2713        let molecule = builder.build().unwrap();
2714
2715        let result = molecule
2716            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_ORGANOMETALLICS)
2717            .unwrap();
2718
2719        let donor_to_busy = result
2720            .bonds()
2721            .iter()
2722            .find(|bond| {
2723                (bond.begin() == donor && bond.end() == metal_busy)
2724                    || (bond.begin() == metal_busy && bond.end() == donor)
2725            })
2726            .unwrap();
2727        let donor_to_open = result
2728            .bonds()
2729            .iter()
2730            .find(|bond| {
2731                (bond.begin() == donor && bond.end() == metal_open)
2732                    || (bond.begin() == metal_open && bond.end() == donor)
2733            })
2734            .unwrap();
2735
2736        assert_eq!(donor_to_busy.order(), crate::BondOrder::Single);
2737        assert_eq!(donor_to_open.order(), crate::BondOrder::Dative);
2738        assert_eq!(donor_to_open.begin(), donor);
2739        assert_eq!(donor_to_open.end(), metal_open);
2740    }
2741
2742    #[test]
2743    fn sanitized_organometallic_cleanup_skips_non_hypervalent_donor_like_rdkit() {
2744        let iron = crate::Element::from_atomic_number(26).expect("iron atomic number is valid");
2745        let mut builder = crate::MoleculeBuilder::new();
2746        let oxygen =
2747            builder.add_atom(crate::AtomSpec::new(crate::Element::O).with_no_implicit(true));
2748        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2749        let metal = builder.add_atom(crate::AtomSpec::new(iron));
2750        builder
2751            .add_bond(crate::BondSpec::new(
2752                oxygen,
2753                carbon,
2754                crate::BondOrder::Single,
2755            ))
2756            .unwrap();
2757        let metal_bond = builder
2758            .add_bond(crate::BondSpec::new(
2759                oxygen,
2760                metal,
2761                crate::BondOrder::Single,
2762            ))
2763            .unwrap();
2764        let molecule = builder.build().unwrap();
2765
2766        let result = molecule
2767            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_ORGANOMETALLICS)
2768            .unwrap();
2769
2770        assert_eq!(
2771            result.bonds()[metal_bond.index()].order(),
2772            crate::BondOrder::Single
2773        );
2774    }
2775
2776    #[test]
2777    fn metal_bond_cleanup_prefers_higher_rank_when_dative_counts_tie() {
2778        let iron = crate::Element::from_atomic_number(26).expect("iron atomic number is valid");
2779        let mut builder = crate::MoleculeBuilder::new();
2780        let donor = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2781        let c1 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2782        let c2 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2783        let c3 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2784        let metal_plain = builder.add_atom(crate::AtomSpec::new(iron));
2785        let metal_substituted = builder.add_atom(crate::AtomSpec::new(iron));
2786        let hydrogen = builder.add_atom(crate::AtomSpec::new(crate::Element::H));
2787        for neighbor in [c1, c2, c3, metal_plain, metal_substituted] {
2788            builder
2789                .add_bond(crate::BondSpec::new(
2790                    donor,
2791                    neighbor,
2792                    crate::BondOrder::Single,
2793                ))
2794                .unwrap();
2795        }
2796        builder
2797            .add_bond(crate::BondSpec::new(
2798                metal_substituted,
2799                hydrogen,
2800                crate::BondOrder::Single,
2801            ))
2802            .unwrap();
2803        let molecule = builder.build().unwrap();
2804        let read = MoleculeReadParts::from_molecule(&molecule);
2805        let adjacency = sanitize_adjacency(read).unwrap();
2806        let valence = read
2807            .assign_valence_with_options(crate::ValenceModel::RdkitLike, false)
2808            .unwrap();
2809        let ranks = read.rank_mol_atoms().unwrap();
2810        let mut assignment = SanitizeOrganometallicCleanupAssignment {
2811            bond_orders: molecule.bonds().iter().map(crate::Bond::order).collect(),
2812            bond_endpoints: molecule
2813                .bonds()
2814                .iter()
2815                .map(|bond| (bond.begin(), bond.end()))
2816                .collect(),
2817        };
2818
2819        sanitize_metal_bond_cleanup_assignment(
2820            read,
2821            &adjacency,
2822            &valence,
2823            &ranks,
2824            donor,
2825            &mut assignment,
2826        )
2827        .unwrap();
2828
2829        let chosen_metal = assignment
2830            .bond_endpoints
2831            .iter()
2832            .zip(assignment.bond_orders.iter())
2833            .find_map(|(&(begin, end), &order)| {
2834                (order == crate::BondOrder::Dative && begin == donor).then_some(end)
2835            })
2836            .unwrap();
2837        let expected = [metal_plain, metal_substituted]
2838            .into_iter()
2839            .max_by_key(|atom| ranks[atom.index()])
2840            .unwrap();
2841        assert_eq!(chosen_metal, expected);
2842    }
2843
2844    #[test]
2845    fn hypervalent_nonmetal_predicate_matches_metal_and_aromatic_degree_four_branches() {
2846        let carbon = crate::Element::C;
2847        let iron = crate::Element::from_atomic_number(26).unwrap();
2848
2849        let mut aromatic_builder = crate::MoleculeBuilder::new();
2850        let sulfur_atom = aromatic_builder.add_atom(
2851            crate::AtomSpec::new(carbon)
2852                .with_aromatic(true)
2853                .with_no_implicit(true),
2854        );
2855        let mut aromatic_neighbors = Vec::new();
2856        for _ in 0..4 {
2857            let carbon = aromatic_builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2858            aromatic_builder
2859                .add_bond(crate::BondSpec::new(
2860                    sulfur_atom,
2861                    carbon,
2862                    crate::BondOrder::Single,
2863                ))
2864                .unwrap();
2865            aromatic_neighbors.push(carbon);
2866        }
2867        let aromatic = aromatic_builder.build().unwrap();
2868        let aromatic_read = MoleculeReadParts::from_molecule(&aromatic);
2869        let aromatic_adj = sanitize_adjacency(aromatic_read).unwrap();
2870        let aromatic_valence = aromatic_read
2871            .assign_valence_with_options(crate::ValenceModel::RdkitLike, false)
2872            .unwrap();
2873
2874        assert!(
2875            sanitize_is_hypervalent_nonmetal(
2876                aromatic_read,
2877                &aromatic_adj,
2878                &aromatic_valence,
2879                sulfur_atom
2880            )
2881            .unwrap()
2882        );
2883
2884        let mut metal_builder = crate::MoleculeBuilder::new();
2885        let metal_atom = metal_builder.add_atom(crate::AtomSpec::new(iron));
2886        let ligand = metal_builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2887        metal_builder
2888            .add_bond(crate::BondSpec::new(
2889                metal_atom,
2890                ligand,
2891                crate::BondOrder::Single,
2892            ))
2893            .unwrap();
2894        let metal_molecule = metal_builder.build().unwrap();
2895        let metal_read = MoleculeReadParts::from_molecule(&metal_molecule);
2896        let metal_adj = sanitize_adjacency(metal_read).unwrap();
2897        let metal_valence = metal_read
2898            .assign_valence_with_options(crate::ValenceModel::RdkitLike, false)
2899            .unwrap();
2900
2901        assert!(
2902            !sanitize_is_hypervalent_nonmetal(metal_read, &metal_adj, &metal_valence, metal_atom)
2903                .unwrap()
2904        );
2905    }
2906
2907    #[test]
2908    fn single_bonded_metals_filters_non_single_and_non_metal_neighbors() {
2909        let iron = crate::Element::from_atomic_number(26).unwrap();
2910        let mut builder = crate::MoleculeBuilder::new();
2911        let donor = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
2912        let metal_single = builder.add_atom(crate::AtomSpec::new(iron));
2913        let metal_rewritten = builder.add_atom(crate::AtomSpec::new(iron));
2914        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2915        let keep_bond = builder
2916            .add_bond(crate::BondSpec::new(
2917                donor,
2918                metal_single,
2919                crate::BondOrder::Single,
2920            ))
2921            .unwrap();
2922        let rewritten_bond = builder
2923            .add_bond(crate::BondSpec::new(
2924                donor,
2925                metal_rewritten,
2926                crate::BondOrder::Single,
2927            ))
2928            .unwrap();
2929        builder
2930            .add_bond(crate::BondSpec::new(
2931                donor,
2932                carbon,
2933                crate::BondOrder::Single,
2934            ))
2935            .unwrap();
2936        let molecule = builder.build().unwrap();
2937        let read = MoleculeReadParts::from_molecule(&molecule);
2938        let adjacency = sanitize_adjacency(read).unwrap();
2939        let mut assignment = SanitizeOrganometallicCleanupAssignment {
2940            bond_orders: molecule.bonds().iter().map(crate::Bond::order).collect(),
2941            bond_endpoints: molecule
2942                .bonds()
2943                .iter()
2944                .map(|bond| (bond.begin(), bond.end()))
2945                .collect(),
2946        };
2947        assignment.bond_orders[rewritten_bond.index()] = crate::BondOrder::Dative;
2948
2949        let metals =
2950            sanitize_organometallic_single_bonded_metals(read, &adjacency, &assignment, donor);
2951
2952        assert_eq!(metals, vec![metal_single]);
2953        assert_eq!(keep_bond.index(), 0);
2954    }
2955
2956    #[test]
2957    fn sanitized_cleanup_atropisomers_clears_non_sp2_atrop_bond_like_rdkit() {
2958        let mut builder = crate::MoleculeBuilder::new();
2959        let left = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2960        let right = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
2961        builder
2962            .add_bond(
2963                crate::BondSpec::new(left, right, crate::BondOrder::Single)
2964                    .with_stereo(crate::BondStereo::AtropCw),
2965            )
2966            .unwrap();
2967        let molecule = builder.build().unwrap();
2968
2969        let result = molecule
2970            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_ATROPISOMERS)
2971            .unwrap();
2972
2973        assert_eq!(result.bonds()[0].stereo(), crate::BondStereo::None);
2974        assert_eq!(result.bonds()[0].stereo_atoms(), None);
2975    }
2976
2977    #[test]
2978    fn sanitized_cleanup_atropisomers_clears_small_ring_atrop_stereo_and_group_like_rdkit() {
2979        let mut builder = crate::MoleculeBuilder::new();
2980        let atoms = (0..6)
2981            .map(|_| {
2982                builder.add_atom(
2983                    crate::AtomSpec::new(crate::Element::C)
2984                        .with_hybridization(crate::Hybridization::Sp2),
2985                )
2986            })
2987            .collect::<Vec<_>>();
2988        let atrop_bond = builder
2989            .add_bond(
2990                crate::BondSpec::new(atoms[0], atoms[1], crate::BondOrder::Single)
2991                    .with_stereo(crate::BondStereo::AtropCcw),
2992            )
2993            .unwrap();
2994        for idx in 1..6 {
2995            builder
2996                .add_bond(crate::BondSpec::new(
2997                    atoms[idx],
2998                    atoms[(idx + 1) % 6],
2999                    crate::BondOrder::Single,
3000                ))
3001                .unwrap();
3002        }
3003        builder
3004            .add_stereo_group(crate::StereoGroup::new(
3005                crate::StereoGroupKind::Or,
3006                Vec::new(),
3007                vec![atrop_bond],
3008            ))
3009            .unwrap();
3010        let molecule = builder.build().unwrap();
3011
3012        let result = molecule
3013            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_ATROPISOMERS)
3014            .unwrap();
3015
3016        assert_eq!(
3017            result.bonds()[atrop_bond.index()].stereo(),
3018            crate::BondStereo::None
3019        );
3020        assert_eq!(result.bonds()[atrop_bond.index()].stereo_atoms(), None);
3021        assert!(result.stereo_groups().is_empty());
3022    }
3023
3024    #[test]
3025    fn sanitized_cleanup_chirality_clears_non_sp3_tetrahedral_tag_like_rdkit() {
3026        let mut builder = crate::MoleculeBuilder::new();
3027        builder.add_atom(
3028            crate::AtomSpec::new(crate::Element::C)
3029                .with_chiral_tag(crate::ChiralTag::TetrahedralCw),
3030        );
3031        let molecule = builder.build().unwrap();
3032
3033        let result = molecule
3034            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_CHIRALITY)
3035            .unwrap();
3036
3037        assert_eq!(
3038            result.atoms()[0].chiral_tag(),
3039            crate::ChiralTag::Unspecified
3040        );
3041    }
3042
3043    #[test]
3044    fn sanitized_cleanup_chirality_cleans_stereo_groups_for_non_sp3_tetrahedral_tags_like_rdkit() {
3045        let mut builder = crate::MoleculeBuilder::new();
3046        let atom = builder.add_atom(
3047            crate::AtomSpec::new(crate::Element::C)
3048                .with_chiral_tag(crate::ChiralTag::TetrahedralCw),
3049        );
3050        builder
3051            .add_stereo_group(crate::StereoGroup::new(
3052                crate::StereoGroupKind::Absolute,
3053                vec![atom],
3054                Vec::new(),
3055            ))
3056            .unwrap();
3057        let molecule = builder.build().unwrap();
3058
3059        let result = molecule
3060            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_CHIRALITY)
3061            .unwrap();
3062
3063        assert_eq!(
3064            result.atoms()[atom.index()].chiral_tag(),
3065            crate::ChiralTag::Unspecified
3066        );
3067        assert!(result.stereo_groups().is_empty());
3068    }
3069
3070    #[test]
3071    fn sanitized_cleanup_chirality_resets_tetrahedral_permutation_above_limit_like_rdkit() {
3072        let mut builder = crate::MoleculeBuilder::new();
3073        builder.add_atom(
3074            crate::AtomSpec::new(crate::Element::C)
3075                .with_chiral_tag(crate::ChiralTag::Tetrahedral)
3076                .with_hybridization(crate::Hybridization::Sp3)
3077                .with_chiral_permutation(7),
3078        );
3079        let molecule = builder.build().unwrap();
3080
3081        let result = molecule
3082            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_CHIRALITY)
3083            .unwrap();
3084
3085        assert_eq!(
3086            result.atoms()[0].chiral_tag(),
3087            crate::ChiralTag::Tetrahedral
3088        );
3089        assert_eq!(result.atoms()[0].chiral_permutation(), Some(0));
3090    }
3091
3092    #[test]
3093    fn sanitized_cleanup_chirality_resets_square_planar_permutation_above_limit_like_rdkit() {
3094        let mut builder = crate::MoleculeBuilder::new();
3095        let center = builder.add_atom(
3096            crate::AtomSpec::new(crate::Element::C)
3097                .with_no_implicit(true)
3098                .with_chiral_tag(crate::ChiralTag::SquarePlanar)
3099                .with_chiral_permutation(7),
3100        );
3101        let left = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3102        let right = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3103        builder
3104            .add_bond(crate::BondSpec::new(center, left, crate::BondOrder::Single))
3105            .unwrap();
3106        builder
3107            .add_bond(crate::BondSpec::new(
3108                center,
3109                right,
3110                crate::BondOrder::Single,
3111            ))
3112            .unwrap();
3113        let molecule = builder.build().unwrap();
3114
3115        let result = molecule
3116            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_CHIRALITY)
3117            .unwrap();
3118
3119        assert_eq!(
3120            result.atoms()[center.index()].chiral_tag(),
3121            crate::ChiralTag::SquarePlanar
3122        );
3123        assert_eq!(result.atoms()[center.index()].chiral_permutation(), Some(0));
3124    }
3125
3126    #[test]
3127    fn sanitized_cleanup_chirality_leaves_invalid_square_planar_stereo_group_untouched_without_tetrahedral_cleanup_flag_like_rdkit()
3128     {
3129        let mut builder = crate::MoleculeBuilder::new();
3130        let center = builder.add_atom(
3131            crate::AtomSpec::new(crate::Element::C)
3132                .with_no_implicit(true)
3133                .with_chiral_tag(crate::ChiralTag::SquarePlanar)
3134                .with_chiral_permutation(3),
3135        );
3136        let neighbor = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3137        builder
3138            .add_bond(crate::BondSpec::new(
3139                center,
3140                neighbor,
3141                crate::BondOrder::Single,
3142            ))
3143            .unwrap();
3144        builder
3145            .add_stereo_group(crate::StereoGroup::new(
3146                crate::StereoGroupKind::Absolute,
3147                vec![center],
3148                Vec::new(),
3149            ))
3150            .unwrap();
3151        let molecule = builder.build().unwrap();
3152
3153        let result = molecule
3154            .sanitize_with_ops(crate::SanitizeOps::CLEANUP_CHIRALITY)
3155            .unwrap();
3156
3157        assert_eq!(
3158            result.atoms()[center.index()].chiral_tag(),
3159            crate::ChiralTag::Unspecified
3160        );
3161        assert_eq!(result.stereo_groups().len(), 1);
3162        assert_eq!(result.stereo_groups()[0].atoms(), &[center]);
3163    }
3164
3165    #[test]
3166    fn sanitized_sets_conjugation_for_butadiene_like_rdkit() {
3167        let molecule = crate::Molecule::from_smiles_with_sanitize("C=CC=C", false).unwrap();
3168
3169        let result = molecule
3170            .sanitize_with_ops(crate::SanitizeOps::PROPERTIES | crate::SanitizeOps::SET_CONJUGATION)
3171            .unwrap();
3172
3173        assert!(result.bonds().iter().all(crate::Bond::is_conjugated));
3174    }
3175
3176    #[test]
3177    fn sanitized_set_conjugation_keeps_aromatic_bonds_conjugated_like_rdkit() {
3178        let molecule = crate::Molecule::from_smiles_with_sanitize("c1ccccc1", false).unwrap();
3179
3180        let result = molecule
3181            .sanitize_with_ops(crate::SanitizeOps::SET_CONJUGATION)
3182            .unwrap();
3183
3184        assert!(result.bonds().iter().all(crate::Bond::is_conjugated));
3185    }
3186
3187    #[test]
3188    fn sanitized_set_conjugation_uses_heteroatom_lone_pair_candidate_like_rdkit() {
3189        let molecule = crate::Molecule::from_smiles_with_sanitize("NC=O", false).unwrap();
3190
3191        let result = molecule
3192            .sanitize_with_ops(crate::SanitizeOps::PROPERTIES | crate::SanitizeOps::SET_CONJUGATION)
3193            .unwrap();
3194
3195        assert_eq!(result.num_bonds(), 2);
3196        assert!(result.bonds().iter().all(crate::Bond::is_conjugated));
3197    }
3198
3199    #[test]
3200    fn sanitized_sets_hybridization_after_conjugation_like_rdkit() {
3201        let molecule = crate::Molecule::from_smiles_with_sanitize("CCO", false).unwrap();
3202
3203        let result = molecule
3204            .sanitize_with_ops(
3205                crate::SanitizeOps::PROPERTIES
3206                    | crate::SanitizeOps::SET_CONJUGATION
3207                    | crate::SanitizeOps::SET_HYBRIDIZATION,
3208            )
3209            .unwrap();
3210
3211        assert_eq!(result.atoms()[0].hybridization(), crate::Hybridization::Sp3);
3212        assert_eq!(result.atoms()[1].hybridization(), crate::Hybridization::Sp3);
3213        assert_eq!(result.atoms()[2].hybridization(), crate::Hybridization::Sp3);
3214    }
3215
3216    #[test]
3217    fn sanitized_set_hybridization_uses_chiral_tag_coordination_override() {
3218        let mut builder = crate::MoleculeBuilder::new();
3219        let center = builder.add_atom(
3220            crate::AtomSpec::new(crate::Element::C)
3221                .with_chiral_tag(crate::ChiralTag::TetrahedralCw),
3222        );
3223        for _ in 0..4 {
3224            let neighbor = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3225            builder
3226                .add_bond(crate::BondSpec::new(
3227                    center,
3228                    neighbor,
3229                    crate::BondOrder::Single,
3230                ))
3231                .unwrap();
3232        }
3233        let molecule = builder.build().unwrap();
3234
3235        let result = molecule
3236            .sanitize_with_ops(
3237                crate::SanitizeOps::PROPERTIES | crate::SanitizeOps::SET_HYBRIDIZATION,
3238            )
3239            .unwrap();
3240
3241        assert_eq!(
3242            result.atoms()[center.index()].hybridization(),
3243            crate::Hybridization::Sp3
3244        );
3245    }
3246
3247    #[test]
3248    fn sanitized_set_hybridization_excludes_dative_bonds_from_num_bonds_plus_lone_pairs() {
3249        let iron = crate::Element::from_atomic_number(26).unwrap();
3250        let mut builder = crate::MoleculeBuilder::new();
3251        let nitrogen =
3252            builder.add_atom(crate::AtomSpec::new(crate::Element::N).with_no_implicit(true));
3253        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3254        let metal = builder.add_atom(crate::AtomSpec::new(iron));
3255        builder
3256            .add_bond(crate::BondSpec::new(
3257                nitrogen,
3258                carbon,
3259                crate::BondOrder::Single,
3260            ))
3261            .unwrap();
3262        builder
3263            .add_bond(crate::BondSpec::new(
3264                nitrogen,
3265                metal,
3266                crate::BondOrder::Dative,
3267            ))
3268            .unwrap();
3269        let molecule = builder.build().unwrap();
3270
3271        let result = molecule
3272            .sanitize_with_ops(
3273                crate::SanitizeOps::PROPERTIES | crate::SanitizeOps::SET_HYBRIDIZATION,
3274            )
3275            .unwrap();
3276
3277        assert_eq!(
3278            result.atoms()[nitrogen.index()].hybridization(),
3279            crate::Hybridization::Sp2
3280        );
3281    }
3282
3283    #[test]
3284    fn sanitized_set_hybridization_excludes_zero_bonds_from_num_bonds_plus_lone_pairs() {
3285        let mut builder = crate::MoleculeBuilder::new();
3286        let oxygen =
3287            builder.add_atom(crate::AtomSpec::new(crate::Element::O).with_no_implicit(true));
3288        let carbon = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3289        let dummy = builder.add_atom(crate::AtomSpec::new(crate::Element::DUMMY));
3290        builder
3291            .add_bond(crate::BondSpec::new(
3292                oxygen,
3293                carbon,
3294                crate::BondOrder::Single,
3295            ))
3296            .unwrap();
3297        builder
3298            .add_bond(crate::BondSpec::new(oxygen, dummy, crate::BondOrder::Zero))
3299            .unwrap();
3300        let molecule = builder.build().unwrap();
3301
3302        let result = molecule
3303            .sanitize_with_ops(
3304                crate::SanitizeOps::PROPERTIES | crate::SanitizeOps::SET_HYBRIDIZATION,
3305            )
3306            .unwrap();
3307
3308        assert_eq!(
3309            result.atoms()[oxygen.index()].hybridization(),
3310            crate::Hybridization::Sp2
3311        );
3312    }
3313
3314    #[test]
3315    fn sanitized_set_hybridization_uses_conjugated_bond_sp2_branch_like_rdkit() {
3316        let molecule = crate::Molecule::from_smiles_with_sanitize("NC=O", false).unwrap();
3317
3318        let result = molecule
3319            .sanitize_with_ops(
3320                crate::SanitizeOps::PROPERTIES
3321                    | crate::SanitizeOps::SET_CONJUGATION
3322                    | crate::SanitizeOps::SET_HYBRIDIZATION,
3323            )
3324            .unwrap();
3325
3326        assert_eq!(result.atoms()[0].hybridization(), crate::Hybridization::Sp2);
3327    }
3328
3329    #[test]
3330    fn sanitized_set_hybridization_uses_atomic_number_cutoff_for_actinides() {
3331        let actinium = crate::Element::from_atomic_number(89).unwrap();
3332        let mut builder = crate::MoleculeBuilder::new();
3333        let center = builder.add_atom(crate::AtomSpec::new(actinium).with_no_implicit(true));
3334        for _ in 0..2 {
3335            let neighbor = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3336            builder
3337                .add_bond(crate::BondSpec::new(
3338                    center,
3339                    neighbor,
3340                    crate::BondOrder::Single,
3341                ))
3342                .unwrap();
3343        }
3344        let molecule = builder.build().unwrap();
3345
3346        let result = molecule
3347            .sanitize_with_ops(
3348                crate::SanitizeOps::PROPERTIES | crate::SanitizeOps::SET_HYBRIDIZATION,
3349            )
3350            .unwrap();
3351
3352        assert_eq!(
3353            result.atoms()[center.index()].hybridization(),
3354            crate::Hybridization::Sp
3355        );
3356    }
3357
3358    #[test]
3359    fn sanitized_find_radicals_recomputes_property_cache_after_topology_state_update() {
3360        let mut builder = crate::MoleculeBuilder::new();
3361        builder.add_atom(
3362            crate::AtomSpec::new(crate::Element::C)
3363                .with_no_implicit(true)
3364                .with_explicit_hydrogens(3),
3365        );
3366        let molecule = builder.build().unwrap();
3367
3368        let result = molecule
3369            .sanitize_with_ops(crate::SanitizeOps::FIND_RADICALS)
3370            .unwrap();
3371
3372        assert_eq!(result.atoms()[0].radical_electrons(), 1);
3373        let expected =
3374            crate::assign_valence_with_options(&result, crate::ValenceModel::RdkitLike, false)
3375                .unwrap();
3376        assert_eq!(result.derived_cache().valence, Some(expected));
3377    }
3378
3379    #[test]
3380    fn sanitize_adjust_hydrogens_assignment_requested_step_does_not_mutate_source() {
3381        let molecule = crate::Molecule::from_smiles_with_sanitize("CCO", false).unwrap();
3382        let original = molecule.clone();
3383
3384        let result = molecule
3385            .sanitize_with_ops(crate::SanitizeOps::ADJUST_HYDROGENS)
3386            .unwrap();
3387
3388        assert_eq!(molecule, original);
3389        assert_eq!(result.num_atoms(), molecule.num_atoms());
3390    }
3391
3392    #[test]
3393    fn sanitize_adjust_hydrogens_assignment_materializes_disappearing_pyrrolic_hydrogen() {
3394        let molecule = crate::Molecule::from_smiles_with_sanitize("N1C=CC=C1", false).unwrap();
3395
3396        let result = molecule
3397            .sanitize_with_ops(
3398                crate::SanitizeOps::PROPERTIES
3399                    | crate::SanitizeOps::SYMMRINGS
3400                    | crate::SanitizeOps::KEKULIZE
3401                    | crate::SanitizeOps::SET_AROMATICITY
3402                    | crate::SanitizeOps::ADJUST_HYDROGENS,
3403            )
3404            .unwrap();
3405
3406        assert!(result.atoms()[0].is_aromatic());
3407        assert_eq!(result.atoms()[0].explicit_hydrogens(), 1);
3408        assert_eq!(
3409            result
3410                .derived_cache()
3411                .valence
3412                .as_ref()
3413                .unwrap()
3414                .implicit_hydrogens[0],
3415            0
3416        );
3417    }
3418
3419    #[test]
3420    fn sanitize_adjust_hydrogens_assignment_preserves_existing_explicit_hydrogen_when_delta_is_zero()
3421     {
3422        let mut builder = crate::MoleculeBuilder::new();
3423        builder.add_atom(
3424            crate::AtomSpec::new(crate::Element::N)
3425                .with_no_implicit(true)
3426                .with_explicit_hydrogens(1),
3427        );
3428        let molecule = builder.build().unwrap();
3429
3430        let result = molecule
3431            .sanitize_with_ops(crate::SanitizeOps::ADJUST_HYDROGENS)
3432            .unwrap();
3433
3434        assert_eq!(result.atoms()[0].explicit_hydrogens(), 1);
3435    }
3436
3437    #[test]
3438    fn sanitize_adjust_hydrogens_assignment_leaves_stable_explicit_hydrogens_unchanged_like_rdkit()
3439    {
3440        let molecule = crate::Molecule::from_smiles_with_sanitize("CCO", false).unwrap();
3441
3442        let result = molecule
3443            .sanitize_with_ops(crate::SanitizeOps::ADJUST_HYDROGENS)
3444            .unwrap();
3445
3446        let explicit_hs = result
3447            .atoms()
3448            .iter()
3449            .map(crate::Atom::explicit_hydrogens)
3450            .collect::<Vec<_>>();
3451        assert_eq!(explicit_hs, vec![0, 0, 0]);
3452        assert_eq!(
3453            molecule
3454                .atoms()
3455                .iter()
3456                .map(crate::Atom::explicit_hydrogens)
3457                .collect::<Vec<_>>(),
3458            explicit_hs
3459        );
3460    }
3461
3462    #[test]
3463    fn experimental_kekulize_runs_through_operation_pipeline_without_changing_source() {
3464        assert_eq!(
3465            WITH_KEKULIZED_BONDS_SPEC.support,
3466            SupportStatus::Experimental
3467        );
3468
3469        let molecule = crate::Molecule::new();
3470        let original = molecule.clone();
3471        let result = molecule
3472            .with_kekulized_bonds(true)
3473            .expect("experimental kekulize skeleton should satisfy op contract");
3474
3475        assert_eq!(molecule, original);
3476        assert_eq!(result.atoms(), original.atoms());
3477        assert_eq!(result.bonds(), original.bonds());
3478        assert_eq!(result.coordinates_2d(), original.coordinates_2d());
3479        assert_eq!(result.conformers_3d(), original.conformers_3d());
3480        assert_eq!(
3481            result.source_coordinate_dim(),
3482            original.source_coordinate_dim()
3483        );
3484        assert_eq!(result.properties(), original.properties());
3485        assert_eq!(
3486            result.derived_cache().valence,
3487            Some(crate::ValenceAssignment {
3488                explicit_valence: Vec::new(),
3489                implicit_hydrogens: Vec::new(),
3490            })
3491        );
3492    }
3493
3494    #[cfg(feature = "op-contracts")]
3495    #[test]
3496    fn op_parts_rejects_permission_violation_under_strict_checks() {
3497        let molecule = crate::Molecule::new();
3498        let mut parts = OpParts::new(&molecule, &WITH_KEKULIZED_BONDS_SPEC).unwrap();
3499        let err = parts
3500            .begin_coordinates_mut()
3501            .expect_err("coordinate begin should be rejected");
3502        assert!(
3503            matches!(err, OperationError::InvalidInput { message, .. } if message.contains("outside its registry access"))
3504        );
3505    }
3506
3507    #[test]
3508    fn needs_update_clears_matching_derived_cache_entries() {
3509        let molecule = crate::Molecule::new();
3510        let mut parts = OpParts::new(&molecule, &SANITIZE_SPEC).unwrap();
3511        parts.set_valence_cache(crate::ValenceAssignment {
3512            explicit_valence: Vec::new(),
3513            implicit_hydrogens: Vec::new(),
3514        });
3515        parts.mark_aromaticity_valid();
3516
3517        parts.clear_cache(SANITIZE_SPEC.needs_update());
3518        let result = parts
3519            .finish(OpOutcome::Changed)
3520            .expect("cache invalidation should satisfy operation contract");
3521
3522        let cache = result.derived_cache();
3523        assert!(cache.valence.is_none());
3524        assert!(cache.rings.is_none());
3525        assert!(cache.ring_families.is_none());
3526        assert!(!cache.aromaticity_valid);
3527        assert!(!cache.stereo_valid);
3528    }
3529
3530    #[test]
3531    fn needs_update_accepts_cache_updates_without_prior_clear() {
3532        let molecule = crate::Molecule::new();
3533        let mut parts = OpParts::new(&molecule, &SANITIZE_SPEC).unwrap();
3534
3535        parts.set_rings_cache(crate::RingInfo::new(crate::RingFindType::SymmSssr, 0, 0));
3536        parts.set_valence_cache(crate::ValenceAssignment {
3537            explicit_valence: Vec::new(),
3538            implicit_hydrogens: Vec::new(),
3539        });
3540        parts.mark_aromaticity_valid();
3541        parts.clear_cache(
3542            DerivedState::RING_FAMILIES
3543                | DerivedState::STEREO
3544                | DerivedState::DRAWING
3545                | DerivedState::FINGERPRINT,
3546        );
3547        let result = parts
3548            .finish(OpOutcome::Changed)
3549            .expect("updated cache entries should satisfy needs_update without clear first");
3550
3551        let cache = result.derived_cache();
3552        assert!(cache.valence.is_some());
3553        assert!(cache.rings.is_some());
3554        assert!(cache.ring_families.is_none());
3555        assert!(cache.aromaticity_valid);
3556        assert!(!cache.stereo_valid);
3557    }
3558
3559    #[cfg(feature = "op-contracts")]
3560    #[test]
3561    fn finish_rejects_missing_needs_update_handling() {
3562        let molecule = crate::Molecule::new();
3563        let parts = OpParts::new(&molecule, &TEST_NEEDS_VALENCE_UPDATE_SPEC).unwrap();
3564
3565        let err = parts
3566            .finish(OpOutcome::NoOp {
3567                reason: "intentionally missed needs_update",
3568            })
3569            .expect_err("needs_update must be cleared or updated before finish");
3570
3571        assert!(matches!(
3572            err,
3573            OperationError::InvalidInput {
3574                message: "operation body did not clear or update every required cache state",
3575                ..
3576            }
3577        ));
3578    }
3579
3580    #[cfg(feature = "op-contracts")]
3581    #[test]
3582    fn finish_rejects_unrelated_cache_clear_for_needs_update() {
3583        let molecule = crate::Molecule::new();
3584        let mut parts = OpParts::new(&molecule, &TEST_NEEDS_VALENCE_UPDATE_SPEC).unwrap();
3585
3586        parts.clear_cache(DerivedState::RINGS);
3587        let err = parts
3588            .finish(OpOutcome::Changed)
3589            .expect_err("clearing rings must not satisfy a valence needs_update contract");
3590
3591        assert!(matches!(
3592            err,
3593            OperationError::InvalidInput {
3594                message: "operation body did not clear or update every required cache state",
3595                ..
3596            }
3597        ));
3598    }
3599
3600    #[cfg(feature = "op-contracts")]
3601    #[test]
3602    fn finish_rejects_declared_preservation_without_proof() {
3603        let molecule = crate::Molecule::new();
3604        let mut parts = OpParts::new(&molecule, &WITH_HYDROGENS_SPEC).unwrap();
3605        let topology = parts.begin_topology_mut().unwrap();
3606        let coordinates = parts.begin_coordinates_mut().unwrap();
3607        let properties = parts.begin_properties_mut().unwrap();
3608
3609        parts.commit_topology(topology).unwrap();
3610        parts.commit_coordinates(coordinates).unwrap();
3611        parts.commit_properties(properties).unwrap();
3612        parts
3613            .record_topology_edit(TopologyEditKind::Appending)
3614            .unwrap();
3615        parts.record_topology_mapping(TopologyMapping::with_appended(0, 0, 0, 0));
3616        parts.clear_cache(WITH_HYDROGENS_SPEC.needs_update());
3617
3618        let err = parts
3619            .finish(OpOutcome::Changed)
3620            .expect_err("declared preserve states require an explicit preservation proof");
3621
3622        assert!(matches!(
3623            err,
3624            OperationError::InvalidInput {
3625                message: "operation body did not prove every declared preserved derived state",
3626                ..
3627            }
3628        ));
3629    }
3630
3631    #[cfg(feature = "op-contracts")]
3632    #[test]
3633    fn leaf_append_preservation_proof_rejects_non_leaf_appended_atom() {
3634        let mut builder = crate::MoleculeBuilder::new();
3635        builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3636        let molecule = builder.build().unwrap();
3637        let mut parts = OpParts::new(&molecule, &WITH_HYDROGENS_SPEC).unwrap();
3638        let mut topology = parts.begin_topology_mut().unwrap();
3639        let coordinates = parts.begin_coordinates_mut().unwrap();
3640        let properties = parts.begin_properties_mut().unwrap();
3641
3642        let appended = AtomId::new(topology.atoms.len());
3643        topology.atoms.push(crate::Atom::from_spec(
3644            appended,
3645            crate::AtomSpec::new(crate::Element::H),
3646        ));
3647        parts.commit_topology(topology).unwrap();
3648        parts.commit_coordinates(coordinates).unwrap();
3649        parts.commit_properties(properties).unwrap();
3650        parts
3651            .record_topology_edit(TopologyEditKind::Appending)
3652            .unwrap();
3653        parts.record_topology_mapping(TopologyMapping::with_appended(1, 0, 1, 0));
3654
3655        let err = parts
3656            .prove_preserved(
3657                DerivedState::RINGS | DerivedState::RING_FAMILIES,
3658                PreservationProof::LeafAtomAppend,
3659            )
3660            .expect_err("an appended atom with no appended leaf bond must not preserve rings");
3661
3662        assert!(matches!(
3663            err,
3664            OperationError::InvalidInput {
3665                message: "leaf-append preservation proof requires every appended atom to be a degree-one leaf",
3666                ..
3667            }
3668        ));
3669    }
3670
3671    #[test]
3672    fn finish_accepts_update_path_for_recompute() {
3673        let molecule = crate::Molecule::new();
3674        let mut parts = OpParts::new(&molecule, &TEST_RECOMPUTE_VALENCE_SPEC).unwrap();
3675
3676        parts.set_valence_cache(crate::ValenceAssignment {
3677            explicit_valence: Vec::new(),
3678            implicit_hydrogens: Vec::new(),
3679        });
3680        let result = parts
3681            .finish(OpOutcome::Changed)
3682            .expect("setting valence cache should satisfy recompute requirement");
3683
3684        assert_eq!(
3685            result.derived_cache().valence,
3686            Some(crate::ValenceAssignment {
3687                explicit_valence: Vec::new(),
3688                implicit_hydrogens: Vec::new(),
3689            })
3690        );
3691        assert_eq!(molecule.derived_cache().valence, None);
3692    }
3693
3694    #[cfg(feature = "op-contracts")]
3695    #[test]
3696    #[should_panic(expected = "cache clear permission violation")]
3697    fn clear_cache_panics_without_derived_cache_permission_when_cache_is_touched() {
3698        let molecule = crate::Molecule::new();
3699        let mut parts = OpParts::new(&molecule, &WITH_2D_COORDINATES_SPEC).unwrap();
3700        parts.clear_cache(DerivedState::VALENCE);
3701    }
3702
3703    #[cfg(not(feature = "op-contracts"))]
3704    #[test]
3705    fn op_contract_checks_are_disabled_without_feature() {
3706        let molecule = crate::Molecule::new();
3707
3708        let mut unauthorized = OpParts::new(&molecule, &WITH_2D_COORDINATES_SPEC).unwrap();
3709        unauthorized.clear_cache(DerivedState::VALENCE);
3710        unauthorized
3711            .finish(OpOutcome::Changed)
3712            .expect("without op-contracts, cache permission checks are disabled");
3713
3714        let missing_update = OpParts::new(&molecule, &TEST_NEEDS_VALENCE_UPDATE_SPEC).unwrap();
3715        missing_update
3716            .finish(OpOutcome::Changed)
3717            .expect("without op-contracts, needs_update checks are disabled");
3718    }
3719
3720    #[cfg(feature = "op-contracts")]
3721    #[test]
3722    #[should_panic(expected = "cache write permission violation")]
3723    fn set_valence_cache_panics_without_requires_or_recompute() {
3724        // WITH_2D_COORDINATES_SPEC has no requires/recompute — set_valence_cache must panic
3725        let molecule = crate::Molecule::new();
3726        let mut parts = OpParts::new(&molecule, &WITH_2D_COORDINATES_SPEC).unwrap();
3727        parts.set_valence_cache(crate::ValenceAssignment {
3728            explicit_valence: Vec::new(),
3729            implicit_hydrogens: Vec::new(),
3730        });
3731    }
3732
3733    #[cfg(feature = "op-contracts")]
3734    #[test]
3735    #[should_panic(expected = "cache clear permission violation")]
3736    fn clear_cache_panics_without_invalidate_or_recompute() {
3737        // TEST_RECOMPUTE_VALENCE_SPEC has VALENCE in recompute (not invalidate)
3738        // RINGS is in neither invalidate nor recompute — clear_cache(DerivedState::RINGS) must panic
3739        let molecule = crate::Molecule::new();
3740        let mut parts = OpParts::new(&molecule, &TEST_RECOMPUTE_VALENCE_SPEC).unwrap();
3741        parts.clear_cache(DerivedState::RINGS);
3742    }
3743
3744    #[test]
3745    fn op_parts_cow_mutation_changes_result_without_changing_source() {
3746        let molecule = crate::Molecule::new();
3747        let mut parts = OpParts::new(&molecule, &WITH_KEKULIZED_BONDS_SPEC).unwrap();
3748
3749        let mut topology = parts.begin_topology_mut().unwrap();
3750        topology.atoms.push(crate::Atom::from_spec(
3751            crate::AtomId::new(0),
3752            crate::AtomSpec::new(crate::Element::C),
3753        ));
3754        let valence = parts
3755            .with_topology_read_parts(topology.clone(), |read| {
3756                read.assign_valence_with_options(crate::ValenceModel::RdkitLike, true)
3757                    .map_err(|source| OperationError::Valence {
3758                        operation: &WITH_KEKULIZED_BONDS_SPEC,
3759                        source,
3760                    })
3761            })
3762            .unwrap();
3763        parts.commit_topology(topology).unwrap();
3764        parts.record_topology_edit(TopologyEditKind::Local).unwrap();
3765        parts.set_rings_cache(crate::RingInfo::new(crate::RingFindType::SymmSssr, 1, 0));
3766        parts.set_valence_cache(valence);
3767        parts.clear_cache(DerivedState::AROMATICITY);
3768        parts.clear_cache(DerivedState::DRAWING | DerivedState::FINGERPRINT);
3769
3770        let result = parts
3771            .finish(OpOutcome::Changed)
3772            .expect("COW topology edit should produce a valid molecule");
3773
3774        assert_eq!(molecule.num_atoms(), 0);
3775        assert_eq!(result.num_atoms(), 1);
3776        assert_eq!(result.atomic_numbers(), vec![6]);
3777    }
3778
3779    #[test]
3780    fn compacting_edit_uses_begin_commit_blocks_and_records_mapping() {
3781        let mut builder = crate::Molecule::builder();
3782        let c0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3783        let o1 = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
3784        let n2 = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
3785        builder
3786            .add_bond(crate::BondSpec::new(c0, o1, crate::BondOrder::Single))
3787            .unwrap();
3788        builder
3789            .add_bond(crate::BondSpec::new(o1, n2, crate::BondOrder::Double))
3790            .unwrap();
3791        builder
3792            .set_2d_coordinates(vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]])
3793            .unwrap();
3794        let properties = crate::MoleculeProperties::default()
3795            .with_sdf_property_list(crate::SdfPropertyList::new(
3796                crate::SdfPropertyListTarget::Atom,
3797                "atom_tag",
3798                vec![
3799                    Some("c0".to_string()),
3800                    Some("o1".to_string()),
3801                    Some("n2".to_string()),
3802                ],
3803            ))
3804            .with_sdf_property_list(crate::SdfPropertyList::new(
3805                crate::SdfPropertyListTarget::Bond,
3806                "bond_tag",
3807                vec![Some("c-o".to_string()), Some("o-n".to_string())],
3808            ));
3809        builder = builder.with_properties(properties);
3810        let molecule = builder.build().unwrap();
3811        let original = molecule.clone();
3812
3813        let mut parts = OpParts::new(&molecule, &WITHOUT_HYDROGENS_SPEC).unwrap();
3814        let mut topology = parts.begin_topology_mut().unwrap();
3815        let mut coordinates = parts.begin_coordinates_mut().unwrap();
3816        let mut properties = parts.begin_properties_mut().unwrap();
3817        let mapping = topology.remove_atoms_with_mapping(&[o1]);
3818        coordinates.remap_topology(&mapping);
3819        properties.remap_topology(&mapping);
3820        parts
3821            .record_topology_edit(TopologyEditKind::Compacting)
3822            .unwrap();
3823        parts.record_topology_mapping(mapping.clone());
3824        assert_eq!(
3825            mapping.atoms().old_to_new(),
3826            &[
3827                Some(crate::AtomId::new(0)),
3828                None,
3829                Some(crate::AtomId::new(1))
3830            ]
3831        );
3832        assert_eq!(mapping.bonds().old_to_new(), &[None, None]);
3833        parts.clear_cache(WITHOUT_HYDROGENS_SPEC.needs_update());
3834        parts.commit_topology(topology).unwrap();
3835        parts.commit_coordinates(coordinates).unwrap();
3836        parts.commit_properties(properties).unwrap();
3837
3838        let result = parts
3839            .finish(OpOutcome::Changed)
3840            .expect("strong compacting edit should satisfy registry contract");
3841
3842        assert_eq!(molecule, original);
3843        assert_eq!(result.atomic_numbers(), vec![6, 7]);
3844        assert_eq!(result.num_bonds(), 0);
3845        assert_eq!(result.coordinates_2d().unwrap(), &[[0.0, 0.0], [2.0, 0.0]]);
3846        assert_eq!(
3847            result.properties().sdf_property_lists()[0].values(),
3848            &[Some("c0".to_string()), Some("n2".to_string())]
3849        );
3850        assert_eq!(result.properties().sdf_property_lists()[1].values(), &[]);
3851    }
3852
3853    #[test]
3854    fn strong_remove_atoms_remaps_surviving_sgroup_parent_relationships() {
3855        let mut builder = crate::Molecule::builder();
3856        let c0 = builder.add_atom(crate::AtomSpec::new(crate::Element::C));
3857        let o1 = builder.add_atom(crate::AtomSpec::new(crate::Element::O));
3858        let n2 = builder.add_atom(crate::AtomSpec::new(crate::Element::N));
3859        builder
3860            .add_substance_group(
3861                crate::SubstanceGroup::new(
3862                    crate::SubstanceGroupId::new(0),
3863                    crate::SubstanceGroupKind::Superatom,
3864                )
3865                .with_atoms(vec![c0]),
3866            )
3867            .unwrap();
3868        builder
3869            .add_substance_group(
3870                crate::SubstanceGroup::new(
3871                    crate::SubstanceGroupId::new(1),
3872                    crate::SubstanceGroupKind::Data,
3873                )
3874                .with_atoms(vec![o1])
3875                .with_parent(crate::SubstanceGroupId::new(0)),
3876            )
3877            .unwrap();
3878        let molecule = builder.build().unwrap();
3879
3880        let mut parts = OpParts::new(&molecule, &WITHOUT_HYDROGENS_SPEC).unwrap();
3881        let mut topology = parts.begin_topology_mut().unwrap();
3882        let mut coordinates = parts.begin_coordinates_mut().unwrap();
3883        let mut properties = parts.begin_properties_mut().unwrap();
3884        let mapping = topology.remove_atoms_with_mapping(&[n2]);
3885        coordinates.remap_topology(&mapping);
3886        properties.remap_topology(&mapping);
3887        parts
3888            .record_topology_edit(TopologyEditKind::Compacting)
3889            .unwrap();
3890        parts.record_topology_mapping(mapping);
3891        parts.clear_cache(WITHOUT_HYDROGENS_SPEC.needs_update());
3892        parts.commit_topology(topology).unwrap();
3893        parts.commit_coordinates(coordinates).unwrap();
3894        parts.commit_properties(properties).unwrap();
3895
3896        let result = parts
3897            .finish(OpOutcome::Changed)
3898            .expect("strong compacting edit should preserve surviving SGroup parent links");
3899
3900        assert_eq!(result.substance_groups().len(), 2);
3901        assert_eq!(
3902            result.substance_groups()[0].atoms(),
3903            &[crate::AtomId::new(0)]
3904        );
3905        assert_eq!(
3906            result.substance_groups()[1].atoms(),
3907            &[crate::AtomId::new(1)]
3908        );
3909        assert_eq!(
3910            result.substance_groups()[1].parent(),
3911            Some(crate::SubstanceGroupId::new(0))
3912        );
3913    }
3914
3915    #[test]
3916    fn with_hydrogens_extends_sdf_property_lists_for_appended_atoms_and_bonds() {
3917        let mut builder = crate::Molecule::builder();
3918        let carbon = builder.add_atom(
3919            crate::AtomSpec::new(crate::Element::C)
3920                .with_explicit_hydrogens(1)
3921                .with_no_implicit(true),
3922        );
3923        let properties = crate::MoleculeProperties::default()
3924            .with_sdf_property_list(crate::SdfPropertyList::new(
3925                crate::SdfPropertyListTarget::Atom,
3926                "atom_tag",
3927                vec![Some("c0".to_string())],
3928            ))
3929            .with_sdf_property_list(crate::SdfPropertyList::new(
3930                crate::SdfPropertyListTarget::Bond,
3931                "bond_tag",
3932                Vec::new(),
3933            ));
3934        builder = builder.with_properties(properties);
3935        let molecule = builder.build().unwrap();
3936
3937        let result = molecule.with_hydrogens().unwrap();
3938
3939        assert_eq!(result.num_atoms(), 2);
3940        assert_eq!(
3941            result.properties().sdf_property_lists()[0].values(),
3942            &[Some("c0".to_string()), None]
3943        );
3944        assert_eq!(
3945            result.properties().sdf_property_lists()[1].values(),
3946            &[None]
3947        );
3948        assert_eq!(result.atoms()[carbon.index()].explicit_hydrogens(), 0);
3949    }
3950
3951    #[cfg(feature = "op-contracts")]
3952    #[test]
3953    fn compacting_topology_edit_record_is_rejected_for_weak_operations() {
3954        let molecule = crate::Molecule::new();
3955        let mut parts = OpParts::new(&molecule, &WITH_KEKULIZED_BONDS_SPEC).unwrap();
3956        let err = parts
3957            .record_topology_edit(TopologyEditKind::Compacting)
3958            .expect_err("weak operations must not record compacting topology edits");
3959        assert!(matches!(err, OperationError::InvalidInput { .. }));
3960    }
3961
3962    #[test]
3963    fn with_kekulized_bonds_uses_rdkit_wrapper_canonical_default_for_benzene() {
3964        let molecule = crate::Molecule::from_smiles("C1=CC=CC=C1").unwrap();
3965
3966        let kekulized = molecule.with_kekulized_bonds(false).unwrap();
3967        let bond_orders = kekulized
3968            .bonds()
3969            .iter()
3970            .map(|bond| bond.order())
3971            .collect::<Vec<_>>();
3972
3973        assert_eq!(
3974            bond_orders,
3975            vec![
3976                crate::BondOrder::Single,
3977                crate::BondOrder::Double,
3978                crate::BondOrder::Single,
3979                crate::BondOrder::Double,
3980                crate::BondOrder::Single,
3981                crate::BondOrder::Double
3982            ]
3983        );
3984        assert!(kekulized.bonds().iter().all(|bond| bond.is_aromatic()));
3985        assert!(kekulized.atoms().iter().all(|atom| atom.is_aromatic()));
3986    }
3987}