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