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