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