smiles-js 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,10 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
- import { Ring, Linear, Molecule } from './constructors.js';
2
+ import {
3
+ Ring, Linear, Molecule,
4
+ } from './constructors.js';
5
+ import {
6
+ repeat, fusedRepeat, linearMirror, moleculeMirror, ringMirror,
7
+ } from './manipulation.js';
3
8
 
4
9
  describe('Ring.attach()', () => {
5
10
  test('attaches a linear chain to a ring', () => {
@@ -442,3 +447,356 @@ describe('Molecule methods', () => {
442
447
  expect(updated.components[1]).toBe(benzene);
443
448
  });
444
449
  });
450
+
451
+ describe('repeat()', () => {
452
+ test('Linear repeat n=1 returns clone', () => {
453
+ const ethylene = Linear(['C', 'C']);
454
+ const result = ethylene.repeat(1, 1, 2);
455
+ expect(result.smiles).toBe('CC');
456
+ expect(result.type).toBe('linear');
457
+ });
458
+
459
+ test('Linear repeat n=3 (polyethylene trimer)', () => {
460
+ const ethylene = Linear(['C', 'C']);
461
+ const result = ethylene.repeat(3, 1, 2);
462
+ expect(result.smiles).toBe('CCCCCC');
463
+ });
464
+
465
+ test('Linear with attachments repeat preserves branches', () => {
466
+ const styrene = Linear(['C', 'C']).attach(2, Ring({ atoms: 'c', size: 6 }));
467
+ const dimer = styrene.repeat(2, 1, 2);
468
+ expect(dimer.smiles).toBe('CC(c1ccccc1)CC(c2ccccc2)');
469
+ });
470
+
471
+ test('Linear with attachments repeat trimer', () => {
472
+ const styrene = Linear(['C', 'C']).attach(2, Ring({ atoms: 'c', size: 6 }));
473
+ const trimer = styrene.repeat(3, 1, 2);
474
+ expect(trimer.smiles).toBe('CC(c1ccccc1)CC(c2ccccc2)CC(c3ccccc3)');
475
+ });
476
+
477
+ test('Ring repeat n=1 returns clone', () => {
478
+ const benzene = Ring({ atoms: 'c', size: 6 });
479
+ const result = benzene.repeat(1, 1, 6);
480
+ expect(result.smiles).toBe('c1ccccc1');
481
+ expect(result.type).toBe('ring');
482
+ });
483
+
484
+ test('Ring repeat n=2 (biphenyl)', () => {
485
+ const benzene = Ring({ atoms: 'c', size: 6 });
486
+ const result = benzene.repeat(2, 1, 6);
487
+ expect(result.smiles).toBe('c1ccccc1c2ccccc2');
488
+ });
489
+
490
+ test('Ring repeat n=3 (terphenyl)', () => {
491
+ const benzene = Ring({ atoms: 'c', size: 6 });
492
+ const result = benzene.repeat(3, 1, 6);
493
+ expect(result.smiles).toBe('c1ccccc1c2ccccc2c3ccccc3');
494
+ });
495
+
496
+ test('Ring with substitutions repeat', () => {
497
+ const pyridine = Ring({ atoms: 'c', size: 6, substitutions: { 3: 'n' } });
498
+ const result = pyridine.repeat(2, 1, 6);
499
+ expect(result.smiles).toBe('c1cnccc1c2cnccc2');
500
+ });
501
+
502
+ test('repeat does not modify original', () => {
503
+ const benzene = Ring({ atoms: 'c', size: 6 });
504
+ benzene.repeat(3, 1, 6);
505
+ expect(benzene.smiles).toBe('c1ccccc1');
506
+ expect(benzene.ringNumber).toBe(1);
507
+ });
508
+
509
+ test('repeat throws for n < 1', () => {
510
+ const ethylene = Linear(['C', 'C']);
511
+ expect(() => ethylene.repeat(0, 1, 2)).toThrow('Repeat count n must be an integer >= 1');
512
+ });
513
+
514
+ test('repeat throws for invalid leftId', () => {
515
+ const ethylene = Linear(['C', 'C']);
516
+ expect(() => ethylene.repeat(2, 0, 2)).toThrow('leftId must be a positive integer');
517
+ });
518
+
519
+ test('repeat throws for invalid rightId', () => {
520
+ const ethylene = Linear(['C', 'C']);
521
+ expect(() => ethylene.repeat(2, 1, 0)).toThrow('rightId must be a positive integer');
522
+ });
523
+
524
+ test('Molecule repeat', () => {
525
+ const unit = Molecule([Linear(['C']), Ring({ atoms: 'c', size: 6 })]);
526
+ const dimer = unit.repeat(2, 1, 1);
527
+ expect(dimer.type).toBe('molecule');
528
+ expect(dimer.smiles).toBe('Cc1ccccc1Cc2ccccc2');
529
+ });
530
+
531
+ test('functional API repeat()', () => {
532
+ const ethylene = Linear(['C', 'C']);
533
+ const result = repeat(ethylene, 3, 1, 2);
534
+ expect(result.smiles).toBe('CCCCCC');
535
+ });
536
+ });
537
+
538
+ describe('fusedRepeat()', () => {
539
+ test('fusedRepeat n=1 returns ring clone', () => {
540
+ const benzene = Ring({ atoms: 'c', size: 6 });
541
+ const result = benzene.fusedRepeat(1, 4);
542
+ expect(result.smiles).toBe('c1ccccc1');
543
+ expect(result.type).toBe('ring');
544
+ });
545
+
546
+ test('fusedRepeat n=2 (naphthalene)', () => {
547
+ const benzene = Ring({ atoms: 'c', size: 6 });
548
+ const result = benzene.fusedRepeat(2, 4);
549
+ expect(result.type).toBe('fused_ring');
550
+ expect(result.rings).toHaveLength(2);
551
+ });
552
+
553
+ test('fusedRepeat n=3 (anthracene)', () => {
554
+ const benzene = Ring({ atoms: 'c', size: 6 });
555
+ const result = benzene.fusedRepeat(3, 4);
556
+ expect(result.type).toBe('fused_ring');
557
+ expect(result.rings).toHaveLength(3);
558
+ });
559
+
560
+ test('fusedRepeat does not modify original', () => {
561
+ const benzene = Ring({ atoms: 'c', size: 6 });
562
+ benzene.fusedRepeat(3, 4);
563
+ expect(benzene.smiles).toBe('c1ccccc1');
564
+ expect(benzene.type).toBe('ring');
565
+ });
566
+
567
+ test('fusedRepeat throws for non-ring', () => {
568
+ expect(() => fusedRepeat(Linear(['C', 'C']), 2, 4)).toThrow('fusedRepeat requires a Ring node');
569
+ });
570
+
571
+ test('fusedRepeat throws for n < 1', () => {
572
+ const benzene = Ring({ atoms: 'c', size: 6 });
573
+ expect(() => benzene.fusedRepeat(0, 4)).toThrow('Repeat count n must be an integer >= 1');
574
+ });
575
+
576
+ test('fusedRepeat throws for invalid offset', () => {
577
+ const benzene = Ring({ atoms: 'c', size: 6 });
578
+ expect(() => benzene.fusedRepeat(2, 0)).toThrow('Fusion offset must be a positive integer');
579
+ });
580
+
581
+ test('functional API fusedRepeat()', () => {
582
+ const benzene = Ring({ atoms: 'c', size: 6 });
583
+ const result = fusedRepeat(benzene, 2, 4);
584
+ expect(result.type).toBe('fused_ring');
585
+ expect(result.rings).toHaveLength(2);
586
+ });
587
+
588
+ test('fusedRepeat with substitutions', () => {
589
+ const ring = Ring({ atoms: 'C', size: 6, substitutions: { 3: 'N' } });
590
+ const result = ring.fusedRepeat(2, 4);
591
+ expect(result.type).toBe('fused_ring');
592
+ expect(result.rings).toHaveLength(2);
593
+ });
594
+ });
595
+
596
+ describe('linearMirror()', () => {
597
+ test('simple chain mirror (diethyl ether)', () => {
598
+ const half = Linear(['C', 'C', 'O']);
599
+ const result = half.mirror();
600
+ expect(result.smiles).toBe('CCOCC');
601
+ });
602
+
603
+ test('default pivot is last atom', () => {
604
+ const half = Linear(['C', 'C', 'C', 'N']);
605
+ const result = half.mirror();
606
+ expect(result.smiles).toBe('CCCNCCC');
607
+ });
608
+
609
+ test('explicit pivot in the middle', () => {
610
+ const chain = Linear(['C', 'C', 'C', 'O', 'C']);
611
+ const result = chain.mirror(4);
612
+ expect(result.smiles).toBe('CCCOCCC');
613
+ });
614
+
615
+ test('single atom mirror returns clone', () => {
616
+ const single = Linear(['O']);
617
+ const result = single.mirror();
618
+ expect(result.smiles).toBe('O');
619
+ });
620
+
621
+ test('two atoms mirror (pivot at 2)', () => {
622
+ const two = Linear(['C', 'O']);
623
+ const result = two.mirror();
624
+ expect(result.smiles).toBe('COC');
625
+ });
626
+
627
+ test('mirrors bonds correctly', () => {
628
+ const half = Linear(['C', 'C', 'O'], ['=']);
629
+ const result = half.mirror();
630
+ expect(result.smiles).toBe('C=COC=C');
631
+ });
632
+
633
+ test('mirrors multiple bond types', () => {
634
+ const half = Linear(['C', 'C', 'N', 'O'], ['=', '#']);
635
+ const result = half.mirror();
636
+ expect(result.smiles).toBe('C=C#NON#C=C');
637
+ });
638
+
639
+ test('mirrors attachments with ring renumbering', () => {
640
+ const half = Linear(['C', 'C', 'O']).attach(
641
+ 1,
642
+ Ring({ atoms: 'c', size: 6 }),
643
+ );
644
+ const result = half.mirror();
645
+ expect(result.smiles).toBe('C(c1ccccc1)COCC(c2ccccc2)');
646
+ });
647
+
648
+ test('does not modify original', () => {
649
+ const half = Linear(['C', 'C', 'O']);
650
+ half.mirror();
651
+ expect(half.smiles).toBe('CCO');
652
+ });
653
+
654
+ test('throws for invalid pivotId', () => {
655
+ const chain = Linear(['C', 'C', 'O']);
656
+ expect(() => chain.mirror(0)).toThrow();
657
+ expect(() => chain.mirror(4)).toThrow();
658
+ });
659
+
660
+ test('functional API linearMirror()', () => {
661
+ const half = Linear(['C', 'C', 'O']);
662
+ const result = linearMirror(half);
663
+ expect(result.smiles).toBe('CCOCC');
664
+ });
665
+ });
666
+
667
+ describe('moleculeMirror()', () => {
668
+ test('ABA triblock copolymer', () => {
669
+ const A = Linear(['C', 'C']);
670
+ const B = Ring({ atoms: 'c', size: 6 });
671
+ const AB = Molecule([A, B]);
672
+ const result = AB.mirror();
673
+ expect(result.smiles).toBe('CCc1ccccc1CC');
674
+ });
675
+
676
+ test('ABCBA from ABC', () => {
677
+ const block = Molecule([
678
+ Linear(['C', 'C']),
679
+ Ring({ atoms: 'c', size: 6 }),
680
+ Linear(['O']),
681
+ ]);
682
+ const result = block.mirror();
683
+ expect(result.smiles).toBe('CCc1ccccc1Oc2ccccc2CC');
684
+ });
685
+
686
+ test('default pivot is last component', () => {
687
+ const mol = Molecule([Linear(['C']), Linear(['N']), Linear(['O'])]);
688
+ const result = mol.mirror();
689
+ expect(result.smiles).toBe('CNONC');
690
+ });
691
+
692
+ test('explicit pivot at component 0 returns clone of single', () => {
693
+ const mol = Molecule([Linear(['C']), Linear(['N'])]);
694
+ const result = mol.mirror(0);
695
+ expect(result.smiles).toBe('C');
696
+ expect(result.components).toHaveLength(1);
697
+ });
698
+
699
+ test('explicit pivot at middle component', () => {
700
+ const mol = Molecule([Linear(['C']), Linear(['N']), Linear(['O'])]);
701
+ const result = mol.mirror(1);
702
+ expect(result.smiles).toBe('CNC');
703
+ });
704
+
705
+ test('does not modify original', () => {
706
+ const mol = Molecule([Linear(['C']), Linear(['N'])]);
707
+ mol.mirror();
708
+ expect(mol.smiles).toBe('CN');
709
+ expect(mol.components).toHaveLength(2);
710
+ });
711
+
712
+ test('throws for invalid pivotComponent', () => {
713
+ const mol = Molecule([Linear(['C']), Linear(['N'])]);
714
+ expect(() => mol.mirror(-1)).toThrow();
715
+ expect(() => mol.mirror(2)).toThrow();
716
+ });
717
+
718
+ test('functional API moleculeMirror()', () => {
719
+ const mol = Molecule([Linear(['C']), Ring({ atoms: 'c', size: 6 })]);
720
+ const result = moleculeMirror(mol);
721
+ expect(result.smiles).toBe('Cc1ccccc1C');
722
+ });
723
+ });
724
+
725
+ describe('ringMirror()', () => {
726
+ test('mirrors substitution (pyrimidine from pyridine)', () => {
727
+ const pyridine = Ring({
728
+ atoms: 'c',
729
+ size: 6,
730
+ substitutions: { 2: 'n' },
731
+ });
732
+ const result = pyridine.mirror(1);
733
+ // pos 2 mirrors to pos 6 around pivot 1
734
+ expect(result.substitutions).toEqual({ 2: 'n', 6: 'n' });
735
+ });
736
+
737
+ test('mirrors attachment to symmetric position', () => {
738
+ const benzene = Ring({ atoms: 'c', size: 6 });
739
+ const mono = benzene.attach(2, Linear(['C']));
740
+ const result = mono.mirror(1);
741
+ // pos 2 mirrors to pos 6 around pivot 1
742
+ expect(result.attachments[2]).toHaveLength(1);
743
+ expect(result.attachments[6]).toHaveLength(1);
744
+ });
745
+
746
+ test('meta substitution pattern via mirror', () => {
747
+ const benzene = Ring({ atoms: 'c', size: 6 });
748
+ const mono = benzene.attach(2, Linear(['C']));
749
+ const result = mono.mirror(3);
750
+ // pos 2 mirrors to pos 4 around pivot 3
751
+ expect(result.smiles).toBe('c1c(C)cc(C)cc1');
752
+ });
753
+
754
+ test('attachment at pivot stays (no duplication)', () => {
755
+ const benzene = Ring({ atoms: 'c', size: 6 });
756
+ const mono = benzene.attach(1, Linear(['C']));
757
+ const result = mono.mirror(1);
758
+ // pos 1 is the pivot, mirrors to self → no duplicate
759
+ expect(result.attachments[1]).toHaveLength(1);
760
+ });
761
+
762
+ test('default pivot is 1', () => {
763
+ const benzene = Ring({ atoms: 'c', size: 6 });
764
+ const mono = benzene.attach(2, Linear(['C']));
765
+ const result = mono.mirror();
766
+ // default pivot=1, pos 2 mirrors to pos 6
767
+ expect(result.attachments[2]).toHaveLength(1);
768
+ expect(result.attachments[6]).toHaveLength(1);
769
+ });
770
+
771
+ test('ring renumbering in mirrored attachments', () => {
772
+ const benzene = Ring({ atoms: 'c', size: 6 });
773
+ const mono = benzene.attach(2, Ring({ atoms: 'c', size: 5 }));
774
+ const result = mono.mirror(1);
775
+ // Attachment at pos 2 has ring with ringNumber=1
776
+ // Mirrored attachment at pos 6 should have ringNumber=2 (shifted)
777
+ const origAtt = result.attachments[2][0];
778
+ const mirrorAtt = result.attachments[6][0];
779
+ expect(origAtt.ringNumber).toBe(1);
780
+ expect(mirrorAtt.ringNumber).toBe(2);
781
+ });
782
+
783
+ test('does not modify original', () => {
784
+ const benzene = Ring({ atoms: 'c', size: 6 });
785
+ const mono = benzene.attach(2, Linear(['C']));
786
+ mono.mirror(1);
787
+ expect(mono.attachments[6]).toBeUndefined();
788
+ });
789
+
790
+ test('throws for invalid pivotId', () => {
791
+ const benzene = Ring({ atoms: 'c', size: 6 });
792
+ expect(() => benzene.mirror(0)).toThrow();
793
+ expect(() => benzene.mirror(7)).toThrow();
794
+ });
795
+
796
+ test('functional API ringMirror()', () => {
797
+ const benzene = Ring({ atoms: 'c', size: 6 });
798
+ const mono = benzene.attach(2, Linear(['C']));
799
+ const result = ringMirror(mono, 3);
800
+ expect(result.smiles).toBe('c1c(C)cc(C)cc1');
801
+ });
802
+ });
@@ -29,6 +29,11 @@ import {
29
29
  moleculeConcat,
30
30
  moleculeGetComponent,
31
31
  moleculeReplaceComponent,
32
+ repeat,
33
+ fusedRepeat,
34
+ linearMirror,
35
+ moleculeMirror,
36
+ ringMirror,
32
37
  } from './manipulation.js';
33
38
 
34
39
  /**
@@ -72,6 +77,15 @@ export function attachRingMethods(node) {
72
77
  addSequentialRings(seqRings, options) {
73
78
  return fusedRingAddSequentialRings(this, seqRings, options);
74
79
  },
80
+ repeat(n, leftId, rightId) {
81
+ return repeat(this, n, leftId, rightId);
82
+ },
83
+ fusedRepeat(n, offset) {
84
+ return fusedRepeat(this, n, offset);
85
+ },
86
+ mirror(pivotId) {
87
+ return ringMirror(this, pivotId);
88
+ },
75
89
  toObject() {
76
90
  const result = {
77
91
  type: this.type,
@@ -109,6 +123,12 @@ export function attachLinearMethods(node) {
109
123
  concat(other) {
110
124
  return linearConcat(this, other);
111
125
  },
126
+ repeat(n, leftId, rightId) {
127
+ return repeat(this, n, leftId, rightId);
128
+ },
129
+ mirror(pivotId) {
130
+ return linearMirror(this, pivotId);
131
+ },
112
132
  clone() {
113
133
  return deepCloneLinear(this);
114
134
  },
@@ -148,6 +168,12 @@ export function attachMoleculeMethods(node) {
148
168
  replaceComponent(index, newComponent) {
149
169
  return moleculeReplaceComponent(this, index, newComponent);
150
170
  },
171
+ repeat(n, leftId, rightId) {
172
+ return repeat(this, n, leftId, rightId);
173
+ },
174
+ mirror(pivotComponent) {
175
+ return moleculeMirror(this, pivotComponent);
176
+ },
151
177
  clone() {
152
178
  return deepCloneMolecule(this);
153
179
  },
@@ -190,6 +216,9 @@ export function attachFusedRingMethods(node) {
190
216
  concat(other) {
191
217
  return fusedRingConcat(this, other);
192
218
  },
219
+ repeat(n, leftId, rightId) {
220
+ return repeat(this, n, leftId, rightId);
221
+ },
193
222
  clone() {
194
223
  return deepCloneFusedRing(this);
195
224
  },
@@ -88,6 +88,7 @@ export function createFusedRingNode(rings, options = {}) {
88
88
  const atomValueMap = new Map();
89
89
  const bondMap = new Map();
90
90
  const ringOrderMap = new Map();
91
+ const branchIdMap = new Map();
91
92
 
92
93
  // Extract ring nodes from metadata - only extract core data fields, not methods
93
94
  const extractedRings = metadata.rings.map((ringMeta) => ringMeta.ring);
@@ -141,6 +142,7 @@ export function createFusedRingNode(rings, options = {}) {
141
142
  if (atom.value !== undefined) atomValueMap.set(atom.position, atom.value);
142
143
  if (atom.bond !== undefined) bondMap.set(atom.position, atom.bond);
143
144
  if (atom.rings !== undefined) ringOrderMap.set(atom.position, atom.rings);
145
+ if (atom.branchId !== undefined) branchIdMap.set(atom.position, atom.branchId);
144
146
  });
145
147
  }
146
148
 
@@ -163,6 +165,7 @@ export function createFusedRingNode(rings, options = {}) {
163
165
  node.metaAtomValueMap = atomValueMap;
164
166
  node.metaBondMap = bondMap;
165
167
  if (ringOrderMap.size > 0) node.metaRingOrderMap = ringOrderMap;
168
+ if (branchIdMap.size > 0) node.metaBranchIdMap = branchIdMap;
166
169
  }
167
170
 
168
171
  // Support standalone atoms format (atoms that are not part of any ring)
@@ -198,6 +201,10 @@ export function createFusedRingNode(rings, options = {}) {
198
201
  if (atom.depth !== undefined) node.metaBranchDepthMap.set(atom.position, atom.depth);
199
202
  if (atom.value !== undefined) node.metaAtomValueMap.set(atom.position, atom.value);
200
203
  if (atom.bond !== undefined) node.metaBondMap.set(atom.position, atom.bond);
204
+ if (atom.branchId !== undefined) {
205
+ if (!node.metaBranchIdMap) node.metaBranchIdMap = new Map();
206
+ node.metaBranchIdMap.set(atom.position, atom.branchId);
207
+ }
201
208
  if (atom.attachments) {
202
209
  if (!node.metaSeqAtomAttachments) node.metaSeqAtomAttachments = new Map();
203
210
  node.metaSeqAtomAttachments.set(atom.position, atom.attachments);
@@ -236,19 +236,34 @@ export function buildAST(atoms, ringBoundaries) {
236
236
  return Molecule([]);
237
237
  }
238
238
 
239
- const mainChainRings = ringBoundaries.filter((r) => r.branchDepth === 0);
239
+ // Include rings that are on the main chain (branchDepth 0) or that have
240
+ // any positions on the main chain (depth 0 atoms in their path)
241
+ const mainChainRings = ringBoundaries.filter(
242
+ (r) => r.branchDepth === 0
243
+ || r.positions.some((pos) => atoms[pos].branchDepth === 0),
244
+ );
240
245
 
241
- const mainChainPositions = new Set();
246
+ const knownPositions = new Set();
242
247
  mainChainRings.forEach((r) => {
243
- r.positions.forEach((pos) => mainChainPositions.add(pos));
248
+ r.positions.forEach((pos) => knownPositions.add(pos));
244
249
  });
245
250
 
246
- const bridgeRings = ringBoundaries.filter((r) => {
247
- if (r.branchDepth === 0) return false;
248
- return r.positions.some((pos) => mainChainPositions.has(pos));
249
- });
251
+ // Iteratively discover bridge rings — rings that share atoms with already-known positions
252
+ const includedRings = new Set(mainChainRings.map((r) => r.ringNumber));
253
+ let foundNew = true;
254
+ while (foundNew) {
255
+ foundNew = false;
256
+ ringBoundaries.forEach((r) => {
257
+ if (includedRings.has(r.ringNumber)) return;
258
+ if (r.positions.some((pos) => knownPositions.has(pos))) {
259
+ includedRings.add(r.ringNumber);
260
+ r.positions.forEach((pos) => knownPositions.add(pos));
261
+ foundNew = true;
262
+ }
263
+ });
264
+ }
250
265
 
251
- const ringsToGroup = [...mainChainRings, ...bridgeRings];
266
+ const ringsToGroup = ringBoundaries.filter((r) => includedRings.has(r.ringNumber));
252
267
  const fusedGroups = groupFusedRings(ringsToGroup);
253
268
 
254
269
  const atomToGroup = new Map();
@@ -87,7 +87,12 @@ export function buildSingleRingNodeWithContext(
87
87
  const ringBranchDepth = ringAtoms[0]?.branchDepth ?? 0;
88
88
  excludedPositions = new Set();
89
89
  ringBoundaries.forEach((rb) => {
90
- if (rb.branchDepth === ringBranchDepth) {
90
+ // Exclude rings at the same branch depth OR rings that have any position
91
+ // at this depth (cross-depth rings like ring 2 in C1C(C1)(C2)CC2)
92
+ const hasAtomAtDepth = rb.positions.some(
93
+ (pos) => atoms[pos].branchDepth === ringBranchDepth,
94
+ );
95
+ if (rb.branchDepth === ringBranchDepth || hasAtomAtDepth) {
91
96
  rb.positions.forEach((pos) => excludedPositions.add(pos));
92
97
  }
93
98
  });
@@ -144,6 +149,7 @@ export function buildSingleRingNodeWithContext(
144
149
  if (hasVaryingDepths) {
145
150
  node.metaIsSibling = !ringPathBranchIdsAfter.has(group.branchId);
146
151
  }
152
+ node.metaBranchId = group.branchId;
147
153
 
148
154
  return node;
149
155
  },
@@ -167,6 +173,7 @@ export function buildSingleRingNodeWithContext(
167
173
  ringNode.metaEnd = ring.end;
168
174
  ringNode.metaBranchDepths = ringAtoms.map((a) => a.branchDepth);
169
175
  ringNode.metaParentIndices = ringAtoms.map((a) => a.parentIndex);
176
+ ringNode.metaBranchIds = ringAtoms.map((a) => a.branchId);
170
177
 
171
178
  // Store leading bond if the first ring atom has a bond
172
179
  const firstRingAtom = ringAtoms[0];
@@ -305,14 +312,16 @@ function buildMetadataMaps(positions, atoms) {
305
312
  const parentIndexMap = new Map();
306
313
  const atomValueMap = new Map();
307
314
  const bondMap = new Map();
315
+ const branchIdMap = new Map();
308
316
  positions.forEach((pos) => {
309
317
  branchDepthMap.set(pos, atoms[pos].branchDepth);
310
318
  parentIndexMap.set(pos, atoms[pos].parentIndex);
311
319
  atomValueMap.set(pos, atoms[pos].rawValue);
312
320
  bondMap.set(pos, atoms[pos].bond);
321
+ branchIdMap.set(pos, atoms[pos].branchId);
313
322
  });
314
323
  return {
315
- branchDepthMap, parentIndexMap, atomValueMap, bondMap,
324
+ branchDepthMap, parentIndexMap, atomValueMap, bondMap, branchIdMap,
316
325
  };
317
326
  }
318
327
 
@@ -390,6 +399,7 @@ function assembleFusedRingNode(
390
399
  fusedNode.metaParentIndexMap = maps.parentIndexMap;
391
400
  fusedNode.metaAtomValueMap = maps.atomValueMap;
392
401
  fusedNode.metaBondMap = maps.bondMap;
402
+ fusedNode.metaBranchIdMap = maps.branchIdMap;
393
403
 
394
404
  // Include sequential ring positions
395
405
  seqRingNodes.forEach((seqRing) => {
@@ -401,6 +411,7 @@ function assembleFusedRingNode(
401
411
  maps.parentIndexMap.set(pos, atoms[pos].parentIndex);
402
412
  maps.atomValueMap.set(pos, atoms[pos].rawValue);
403
413
  maps.bondMap.set(pos, atoms[pos].bond);
414
+ maps.branchIdMap.set(pos, atoms[pos].branchId);
404
415
  }
405
416
  });
406
417
  });
@@ -426,6 +437,7 @@ function assembleFusedRingNode(
426
437
  fusedNode.metaParentIndexMap = maps.parentIndexMap;
427
438
  fusedNode.metaAtomValueMap = maps.atomValueMap;
428
439
  fusedNode.metaBondMap = maps.bondMap;
440
+ fusedNode.metaBranchIdMap = maps.branchIdMap;
429
441
 
430
442
  // Ring order map — tracks which ring markers appear at each position
431
443
  const ringOrderMap = new Map();
@@ -90,10 +90,25 @@ export function collectRingPath(
90
90
  const innerRings = findInnerFusedRings(startIdx, endIdx, atoms, branchDepth, closedRings);
91
91
  const endAtom = atoms[endIdx];
92
92
  const ringEntersDeepBranch = endAtom && endAtom.branchDepth > branchDepth;
93
+ const ringExitsToShallowerDepth = endAtom && endAtom.branchDepth < branchDepth;
93
94
  const ringPathBranchIds = ringEntersDeepBranch
94
95
  ? traceRingPathBranchIds(endAtom, branchDepth, atoms)
95
96
  : new Set();
96
97
 
98
+ // When ring exits to a shallower depth, trace from start atom to find branch IDs
99
+ // that contain the start, and also include atoms at the shallower end depth
100
+ const exitBranchIds = new Set();
101
+ if (ringExitsToShallowerDepth) {
102
+ let traceAtom = atoms[startIdx];
103
+ while (traceAtom && traceAtom.branchDepth > endAtom.branchDepth) {
104
+ if (traceAtom.branchId !== null) {
105
+ exitBranchIds.add(traceAtom.branchId);
106
+ }
107
+ const parentIdx = traceAtom.parentIndex;
108
+ traceAtom = parentIdx !== null ? atoms[parentIdx] : null;
109
+ }
110
+ }
111
+
97
112
  let idx = startIdx;
98
113
  while (idx <= endIdx) {
99
114
  const atom = atoms[idx];
@@ -105,6 +120,19 @@ export function collectRingPath(
105
120
  if (innerRing) {
106
121
  positions.push(idx);
107
122
  idx = innerRing.end;
123
+ } else if (ringExitsToShallowerDepth) {
124
+ // For rings that exit to shallower depth:
125
+ // Include atoms at the start's branch context OR at the end's depth
126
+ const inStartBranch = atom.branchDepth === branchDepth
127
+ && (branchDepth === 0 || atom.branchId === branchId);
128
+ const inExitBranch = exitBranchIds.has(atom.branchId);
129
+ const atEndDepth = atom.branchDepth === endAtom.branchDepth
130
+ && (endAtom.branchDepth === 0
131
+ || isInSameBranchContext(atom, endAtom, atoms));
132
+ if (inStartBranch || inExitBranch || atEndDepth) {
133
+ positions.push(idx);
134
+ }
135
+ idx += 1;
108
136
  } else {
109
137
  const startAtom = atoms[startIdx];
110
138
  if (shouldIncludeAtomInRing(
@@ -458,7 +458,7 @@ exports[`Carbamazepine Integration Test generates valid code via toCode() 1`] =
458
458
  export const v2 = Ring({ atoms: 'C', size: 7, ringNumber: 2, offset: 3, bonds: [null, null, '=', null, '=', null, null], leadingBond: '=' });
459
459
  export const v3 = v2.substitute(7, 'N');
460
460
  export const v4 = Ring({ atoms: 'C', size: 6, ringNumber: 3, bonds: ['=', null, '=', null, '=', null] });
461
- export const v5 = FusedRing({ metadata: { rings: [{ ring: v1, start: 0, end: 5, atoms: [{ position: 0, depth: 0, value: 'C', rings: [1, 1] }, { position: 1, depth: 0, value: 'C', bond: '=', rings: [1] }, { position: 2, depth: 0, value: 'C', rings: [1] }, { position: 3, depth: 0, value: 'C', bond: '=', rings: [2, 1, 2] }, { position: 4, depth: 0, value: 'C', rings: [1, 2] }, { position: 5, depth: 1, value: 'C', bond: '=', rings: [1] }] }, { ring: v3, start: 3, end: 14, atoms: [{ position: 3, depth: 0, value: 'C', bond: '=', rings: [2, 1, 2] }, { position: 4, depth: 0, value: 'C', rings: [1, 2] }, { position: 6, depth: 0, value: 'C', rings: [2] }, { position: 7, depth: 0, value: 'C', bond: '=', rings: [2] }, { position: 8, depth: 0, value: 'C', rings: [3, 3, 2] }, { position: 13, depth: 0, value: 'C', bond: '=', rings: [3, 2] }, { position: 14, depth: 0, value: 'N', rings: [2] }] }, { ring: v4, start: 8, end: 13, atoms: [{ position: 8, depth: 0, value: 'C', rings: [3, 3, 2] }, { position: 9, depth: 0, value: 'C', bond: '=', rings: [3] }, { position: 10, depth: 0, value: 'C', rings: [3] }, { position: 11, depth: 0, value: 'C', bond: '=', rings: [3] }, { position: 12, depth: 0, value: 'C', rings: [3] }, { position: 13, depth: 0, value: 'C', bond: '=', rings: [3, 2] }] }] } });
461
+ export const v5 = FusedRing({ metadata: { rings: [{ ring: v1, start: 0, end: 5, atoms: [{ position: 0, depth: 0, value: 'C', rings: [1, 1] }, { position: 1, depth: 0, value: 'C', bond: '=', rings: [1] }, { position: 2, depth: 0, value: 'C', rings: [1] }, { position: 3, depth: 0, value: 'C', bond: '=', rings: [2, 1, 2] }, { position: 4, depth: 0, value: 'C', rings: [1, 2] }, { position: 5, depth: 1, value: 'C', bond: '=', rings: [1], branchId: 9 }] }, { ring: v3, start: 3, end: 14, atoms: [{ position: 3, depth: 0, value: 'C', bond: '=', rings: [2, 1, 2] }, { position: 4, depth: 0, value: 'C', rings: [1, 2] }, { position: 6, depth: 0, value: 'C', rings: [2] }, { position: 7, depth: 0, value: 'C', bond: '=', rings: [2] }, { position: 8, depth: 0, value: 'C', rings: [3, 3, 2] }, { position: 13, depth: 0, value: 'C', bond: '=', rings: [3, 2] }, { position: 14, depth: 0, value: 'N', rings: [2] }] }, { ring: v4, start: 8, end: 13, atoms: [{ position: 8, depth: 0, value: 'C', rings: [3, 3, 2] }, { position: 9, depth: 0, value: 'C', bond: '=', rings: [3] }, { position: 10, depth: 0, value: 'C', rings: [3] }, { position: 11, depth: 0, value: 'C', bond: '=', rings: [3] }, { position: 12, depth: 0, value: 'C', rings: [3] }, { position: 13, depth: 0, value: 'C', bond: '=', rings: [3, 2] }] }] } });
462
462
  export const v6 = Linear(['C', 'N']);
463
463
  export const v7 = Linear(['O'], ['=']);
464
464
  export const v8 = v6.attach(1, v7);