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.
- package/API.md +206 -9
- package/README.md +39 -0
- package/docs/MIRROR_PLAN.md +204 -0
- package/package.json +1 -1
- package/scripts/coverage-summary.js +1 -1
- package/src/codegen/branch-crossing-ring.js +27 -6
- package/src/codegen/interleaved-fused-ring.js +24 -0
- package/src/decompiler.js +53 -123
- package/src/decompiler.test.js +56 -8
- package/src/manipulation.js +443 -17
- package/src/manipulation.test.js +359 -1
- package/src/method-attachers.js +29 -0
- package/src/node-creators.js +7 -0
- package/src/parser/ast-builder.js +23 -8
- package/src/parser/ring-group-builder.js +14 -2
- package/src/parser/ring-utils.js +28 -0
- package/test-integration/__snapshots__/adjuvant-analgesics.test.js.snap +1 -1
- package/test-integration/__snapshots__/cholesterol-drugs.test.js.snap +189 -23
- package/test-integration/__snapshots__/endocannabinoids.test.js.snap +2 -2
- package/test-integration/__snapshots__/hypertension-medication.test.js.snap +1 -1
- package/test-integration/__snapshots__/nsaids-otc.test.js.snap +1 -1
- package/test-integration/__snapshots__/nsaids-prescription.test.js.snap +2 -2
- package/test-integration/__snapshots__/opioids.test.js.snap +4 -4
- package/test-integration/__snapshots__/steroids.test.js.snap +2 -2
- package/test-integration/cholesterol-drugs.test.js +41 -0
- package/test-integration/cholesterol.test.js +112 -0
- package/test-integration/mirror.test.js +151 -0
- package/test-integration/polymer.test.js +148 -0
- package/todo +0 -2
package/src/manipulation.test.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, test, expect } from 'bun:test';
|
|
2
|
-
import {
|
|
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
|
+
});
|
package/src/method-attachers.js
CHANGED
|
@@ -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
|
},
|
package/src/node-creators.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
246
|
+
const knownPositions = new Set();
|
|
242
247
|
mainChainRings.forEach((r) => {
|
|
243
|
-
r.positions.forEach((pos) =>
|
|
248
|
+
r.positions.forEach((pos) => knownPositions.add(pos));
|
|
244
249
|
});
|
|
245
250
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 =
|
|
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
|
-
|
|
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();
|
package/src/parser/ring-utils.js
CHANGED
|
@@ -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);
|