smiles-js 2.0.3 → 2.2.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.
Files changed (49) hide show
  1. package/API.md +162 -0
  2. package/README.md +39 -0
  3. package/docs/MIRROR_PLAN.md +204 -0
  4. package/docs/smiles.peggy +215 -0
  5. package/package.json +1 -1
  6. package/scripts/coverage-summary.js +1 -1
  7. package/src/codegen/branch-crossing-ring.js +27 -6
  8. package/src/codegen/interleaved-fused-ring.js +24 -0
  9. package/src/decompiler.js +236 -51
  10. package/src/decompiler.test.js +232 -60
  11. package/src/fragment.test.js +7 -2
  12. package/src/manipulation.js +409 -4
  13. package/src/manipulation.test.js +359 -1
  14. package/src/method-attachers.js +37 -8
  15. package/src/node-creators.js +7 -0
  16. package/src/parser/ast-builder.js +23 -8
  17. package/src/parser/ring-group-builder.js +14 -2
  18. package/src/parser/ring-utils.js +28 -0
  19. package/test-integration/__snapshots__/acetaminophen.test.js.snap +20 -0
  20. package/test-integration/__snapshots__/adjuvant-analgesics.test.js.snap +63 -1
  21. package/test-integration/__snapshots__/cholesterol-drugs.test.js.snap +437 -0
  22. package/test-integration/__snapshots__/dexamethasone.test.js.snap +31 -0
  23. package/test-integration/__snapshots__/endocannabinoids.test.js.snap +79 -2
  24. package/test-integration/__snapshots__/endogenous-opioids.test.js.snap +1116 -0
  25. package/test-integration/__snapshots__/hypertension-medication.test.js.snap +70 -1
  26. package/test-integration/__snapshots__/local-anesthetics.test.js.snap +97 -0
  27. package/test-integration/__snapshots__/nsaids-otc.test.js.snap +61 -1
  28. package/test-integration/__snapshots__/nsaids-prescription.test.js.snap +115 -2
  29. package/test-integration/__snapshots__/opioids.test.js.snap +113 -4
  30. package/test-integration/__snapshots__/steroids.test.js.snap +381 -2
  31. package/test-integration/acetaminophen.test.js +15 -3
  32. package/test-integration/adjuvant-analgesics.test.js +43 -7
  33. package/test-integration/cholesterol-drugs.test.js +127 -20
  34. package/test-integration/cholesterol.test.js +112 -0
  35. package/test-integration/dexamethasone.test.js +8 -2
  36. package/test-integration/endocannabinoids.test.js +48 -12
  37. package/test-integration/endogenous-opioids.smiles.js +32 -0
  38. package/test-integration/endogenous-opioids.test.js +192 -0
  39. package/test-integration/hypertension-medication.test.js +32 -8
  40. package/test-integration/local-anesthetics.smiles.js +33 -0
  41. package/test-integration/local-anesthetics.test.js +64 -16
  42. package/test-integration/mirror.test.js +151 -0
  43. package/test-integration/nsaids-otc.test.js +40 -10
  44. package/test-integration/nsaids-prescription.test.js +72 -18
  45. package/test-integration/opioids.test.js +56 -14
  46. package/test-integration/polymer.test.js +148 -0
  47. package/test-integration/steroids.test.js +112 -28
  48. package/test-integration/utils.js +4 -2
  49. package/todo +2 -3
@@ -76,14 +76,35 @@ export function buildBranchCrossingRingSMILES(ring, buildSMILES) {
76
76
 
77
77
  if (attachments[i] && attachments[i].length > 0) {
78
78
  if (hasInlineBranchAfter) {
79
- // Delay attachments - output after inline branch closes back to this depth
80
- const processedAttachments = attachments[i].map((att) => {
81
- if (att.metaIsSibling !== undefined) {
82
- return att;
79
+ // Determine the branchId of the inline continuation (next ring position)
80
+ const metaBranchIds = ring.metaBranchIds || [];
81
+ const inlineBranchId = i < size ? (metaBranchIds[i] || Infinity) : Infinity;
82
+
83
+ // Separate: siblings that come BEFORE the inline branch (emit now)
84
+ // vs siblings that come AFTER (delay), and non-siblings (delay)
85
+ const emitNow = [];
86
+ const delay = [];
87
+ attachments[i].forEach((att) => {
88
+ const isSibling = att.metaIsSibling !== undefined ? att.metaIsSibling : true;
89
+ if (!isSibling) {
90
+ delay.push(att);
91
+ } else if (att.metaBranchId !== undefined && att.metaBranchId < inlineBranchId) {
92
+ emitNow.push(att);
93
+ } else if (att.metaBeforeInline === true) {
94
+ emitNow.push(att);
95
+ } else {
96
+ // Default: delay sibling (beforeInline defaults to false)
97
+ delay.push({ ...att, metaIsSibling: true });
83
98
  }
84
- return { ...att, metaIsSibling: true };
85
99
  });
86
- pendingAttachments.set(i, { depth: posDepth, attachments: processedAttachments });
100
+ // Emit siblings that come before the inline branch
101
+ emitNow.forEach((attachment) => {
102
+ emitAttachment(parts, attachment, buildSMILES);
103
+ });
104
+ // Delay the rest
105
+ if (delay.length > 0) {
106
+ pendingAttachments.set(i, { depth: posDepth, attachments: delay });
107
+ }
87
108
  } else {
88
109
  // Output attachments immediately
89
110
  attachments[i].forEach((attachment) => {
@@ -103,11 +103,33 @@ export function buildInterleavedFusedRingSMILES(fusedRing, buildSMILES) {
103
103
  // Pending attachments: depth -> attachment[]
104
104
  const pendingAttachments = new Map();
105
105
 
106
+ const branchIdMap = fusedRing.metaBranchIdMap || new Map();
107
+ let prevBranchId = null;
108
+
106
109
  allPositions.forEach((pos, idx) => {
107
110
  const entry = atomSequence[pos];
108
111
  if (!entry) return;
109
112
 
110
113
  const posDepth = branchDepthMap.get(pos) || 0;
114
+ const posBranchId = branchIdMap.get(pos);
115
+
116
+ // Detect sibling branch switch: same depth, different branch ID
117
+ // Need to close current branch and reopen a new one
118
+ if (posDepth > 0 && posDepth === depthRef.value && prevBranchId !== null
119
+ && posBranchId !== prevBranchId && posBranchId !== null) {
120
+ // Close the current branch and reopen
121
+ parts.push(')');
122
+ // Emit any pending attachments at the parent depth
123
+ const parentDepth = posDepth - 1;
124
+ if (pendingAttachments.has(parentDepth)) {
125
+ const attachmentsToOutput = pendingAttachments.get(parentDepth);
126
+ attachmentsToOutput.forEach((attachment) => {
127
+ emitAttachment(parts, attachment, buildSMILES);
128
+ });
129
+ pendingAttachments.delete(parentDepth);
130
+ }
131
+ parts.push('(');
132
+ }
111
133
 
112
134
  // Handle branch depth changes
113
135
  openBranches(parts, depthRef, posDepth);
@@ -164,6 +186,8 @@ export function buildInterleavedFusedRingSMILES(fusedRing, buildSMILES) {
164
186
  });
165
187
  }
166
188
  }
189
+
190
+ prevBranchId = posBranchId;
167
191
  });
168
192
 
169
193
  // Close any remaining open branches
package/src/decompiler.js CHANGED
@@ -9,11 +9,13 @@ import {
9
9
  isRingNode,
10
10
  isLinearNode,
11
11
  } from './ast.js';
12
+ import { buildSMILES } from './codegen/index.js';
13
+ import { createRingNode } from './node-creators.js';
12
14
 
13
15
  // Helper to call decompileNode (satisfies no-loop-func rule)
14
- function decompileChildNode(node, indent, nextVar) {
16
+ function decompileChildNode(node, indent, nextVar, verbose) {
15
17
  // eslint-disable-next-line no-use-before-define
16
- return decompileNode(node, indent, nextVar);
18
+ return decompileNode(node, indent, nextVar, verbose);
17
19
  }
18
20
 
19
21
  /**
@@ -110,22 +112,43 @@ function generateSubstitutionCode(ring, indent, nextVar, initialVar) {
110
112
  * Generate code for ring attachments
111
113
  * @returns {{ lines: string[], currentVar: string }}
112
114
  */
113
- function generateAttachmentCode(ring, indent, nextVar, initialVar) {
115
+ function generateAttachmentCode(ring, indent, nextVar, initialVar, verbose = true) {
114
116
  const lines = [];
115
117
  let currentVar = initialVar;
116
118
 
117
119
  if (ring.attachments && Object.keys(ring.attachments).length > 0) {
120
+ // Determine inline branchId for sibling ordering
121
+ const metaBranchIds = ring.metaBranchIds || [];
122
+ const normalizedDepths = ring.metaBranchDepths || [];
123
+
118
124
  Object.entries(ring.attachments).forEach(([pos, attachmentList]) => {
125
+ const posIdx = Number(pos) - 1; // 0-indexed
126
+ const nextIdx = posIdx + 1;
127
+ const posDepth = normalizedDepths[posIdx] || 0;
128
+ const nextDepth = nextIdx < normalizedDepths.length ? (normalizedDepths[nextIdx] || 0) : 0;
129
+ const hasInlineBranchAfter = nextDepth > posDepth;
130
+ const inlineBranchId = hasInlineBranchAfter ? (metaBranchIds[nextIdx] || Infinity) : Infinity;
131
+
119
132
  attachmentList.forEach((attachment) => {
120
- const attachResult = decompileChildNode(attachment, indent, nextVar);
133
+ const attachResult = decompileChildNode(attachment, indent, nextVar, verbose);
121
134
  lines.push(attachResult.code);
122
135
 
123
136
  const newVar = nextVar();
124
137
  const isSibling = attachment.metaIsSibling;
138
+ const optParts = [];
125
139
  if (isSibling === true) {
126
- lines.push(`${indent}const ${newVar} = ${currentVar}.attach(${pos}, ${attachResult.finalVar}, { sibling: true });`);
140
+ optParts.push('sibling: true');
141
+ // Encode beforeInline for sibling ordering in branch-crossing rings
142
+ // Only emit when true since false is the default
143
+ if (hasInlineBranchAfter && attachment.metaBranchId !== undefined
144
+ && attachment.metaBranchId < inlineBranchId) {
145
+ optParts.push('beforeInline: true');
146
+ }
127
147
  } else if (isSibling === false) {
128
- lines.push(`${indent}const ${newVar} = ${currentVar}.attach(${pos}, ${attachResult.finalVar}, { sibling: false });`);
148
+ optParts.push('sibling: false');
149
+ }
150
+ if (optParts.length > 0) {
151
+ lines.push(`${indent}const ${newVar} = ${currentVar}.attach(${pos}, ${attachResult.finalVar}, { ${optParts.join(', ')} });`);
129
152
  } else {
130
153
  lines.push(`${indent}const ${newVar} = ${currentVar}.attach(${pos}, ${attachResult.finalVar});`);
131
154
  }
@@ -137,13 +160,72 @@ function generateAttachmentCode(ring, indent, nextVar, initialVar) {
137
160
  return { lines, currentVar };
138
161
  }
139
162
 
163
+ /**
164
+ * Compute the SMILES for a ring node with substitutions but without attachments.
165
+ * Used in non-verbose mode to emit Fragment('SMILES') for the substituted ring.
166
+ */
167
+ function getRingWithSubsSmiles(ring) {
168
+ const tempNode = createRingNode(
169
+ ring.atoms,
170
+ ring.size,
171
+ ring.ringNumber,
172
+ ring.offset,
173
+ ring.substitutions,
174
+ {},
175
+ ring.bonds,
176
+ ring.metaBranchDepths,
177
+ );
178
+ if (ring.metaLeadingBond) {
179
+ tempNode.metaLeadingBond = ring.metaLeadingBond;
180
+ }
181
+ return buildSMILES(tempNode);
182
+ }
183
+
140
184
  /**
141
185
  * Decompile a Ring node
142
186
  */
143
- function decompileRing(ring, indent, nextVar) {
187
+ function decompileRing(ring, indent, nextVar, verbose = true) {
144
188
  const lines = [];
145
189
  const varName = nextVar();
146
190
 
191
+ if (!verbose && !ring.metaLeadingBond) {
192
+ // Non-verbose: emit Fragment('SMILES') for base ring
193
+ const baseSmiles = buildSMILES(createRingNode(
194
+ ring.atoms,
195
+ ring.size,
196
+ ring.ringNumber,
197
+ ring.offset,
198
+ {},
199
+ {},
200
+ ring.bonds,
201
+ ring.metaBranchDepths,
202
+ ));
203
+ lines.push(`${indent}const ${varName} = Fragment('${baseSmiles}');`);
204
+
205
+ // Substitutions: each becomes an independent Fragment('SMILES')
206
+ let currentVar = varName;
207
+ if (Object.keys(ring.substitutions).length > 0) {
208
+ const subsSmiles = getRingWithSubsSmiles(ring);
209
+ const newVar = nextVar();
210
+ lines.push(`${indent}const ${newVar} = Fragment('${subsSmiles}');`);
211
+ currentVar = newVar;
212
+ }
213
+
214
+ // Attachments stay as .attach() calls
215
+ const { lines: attLines, currentVar: attVar } = generateAttachmentCode(
216
+ ring,
217
+ indent,
218
+ nextVar,
219
+ currentVar,
220
+ verbose,
221
+ );
222
+ lines.push(...attLines);
223
+ currentVar = attVar;
224
+
225
+ return { code: lines.join('\n'), finalVar: currentVar };
226
+ }
227
+
228
+ // Verbose mode (original behavior)
147
229
  // Build options object (include branchDepths for full decompilation)
148
230
  const { optionsStr } = buildRingOptions(ring, { includeBranchDepths: true });
149
231
  lines.push(`${indent}const ${varName} = Ring({ ${optionsStr} });`);
@@ -164,6 +246,7 @@ function decompileRing(ring, indent, nextVar) {
164
246
  indent,
165
247
  nextVar,
166
248
  currentVar,
249
+ verbose,
167
250
  );
168
251
  lines.push(...attLines);
169
252
  currentVar = attVar;
@@ -174,7 +257,7 @@ function decompileRing(ring, indent, nextVar) {
174
257
  /**
175
258
  * Decompile a Linear node
176
259
  */
177
- function decompileLinear(linear, indent, nextVar) {
260
+ function decompileLinear(linear, indent, nextVar, verbose = true) {
178
261
  const lines = [];
179
262
  const varName = nextVar();
180
263
 
@@ -184,7 +267,13 @@ function decompileLinear(linear, indent, nextVar) {
184
267
  const hasNonNullBonds = linear.bonds.some((b) => b !== null);
185
268
  const hasLeadingBond = linear.metaLeadingBond !== undefined;
186
269
 
187
- if (hasNonNullBonds && hasLeadingBond) {
270
+ // Non-verbose: use Fragment('SMILES') when possible (no bonds, no leadingBond)
271
+ if (!verbose && !hasNonNullBonds && !hasLeadingBond) {
272
+ // Compute SMILES without attachments for the base linear node
273
+ const baseLinear = { ...linear, attachments: {} };
274
+ const smiles = buildSMILES(baseLinear);
275
+ lines.push(`${indent}const ${varName} = Fragment('${smiles}');`);
276
+ } else if (hasNonNullBonds && hasLeadingBond) {
188
277
  lines.push(`${indent}const ${varName} = Linear([${atomsStr}], [${formatBondsArray(linear.bonds)}], {}, '${linear.metaLeadingBond}');`);
189
278
  } else if (hasNonNullBonds) {
190
279
  lines.push(`${indent}const ${varName} = Linear([${atomsStr}], [${formatBondsArray(linear.bonds)}]);`);
@@ -201,7 +290,7 @@ function decompileLinear(linear, indent, nextVar) {
201
290
  Object.entries(linear.attachments).forEach(([pos, attachmentList]) => {
202
291
  attachmentList.forEach((attachment) => {
203
292
  // eslint-disable-next-line no-use-before-define
204
- const attachRes = decompileNode(attachment, indent, nextVar);
293
+ const attachRes = decompileNode(attachment, indent, nextVar, verbose);
205
294
  const { code: aCode, finalVar: aFinalVar } = attachRes;
206
295
  lines.push(aCode);
207
296
 
@@ -402,7 +491,7 @@ function computeSharedPositions(fusedRing) {
402
491
  * Emits .fuse() for the first pair and .addRing() for subsequent rings.
403
492
  * The resulting code goes through the simple codegen path (offset-based).
404
493
  */
405
- function decompileSimpleFusedRing(fusedRing, indent, nextVar) {
494
+ function decompileSimpleFusedRing(fusedRing, indent, nextVar, verbose = true) {
406
495
  const lines = [];
407
496
  const ringFinalVars = [];
408
497
 
@@ -465,20 +554,52 @@ function decompileSimpleFusedRing(fusedRing, indent, nextVar) {
465
554
  }
466
555
 
467
556
  const varName = nextVar();
468
- const { optionsStr } = buildRingOptions(effectiveRing, { includeBranchDepths: true });
469
- lines.push(`${indent}const ${varName} = Ring({ ${optionsStr} });`);
470
557
 
471
- const {
472
- lines: subLines, currentVar: subVar,
473
- } = generateSubstitutionCode(effectiveRing, indent, nextVar, varName);
474
- lines.push(...subLines);
558
+ if (!verbose && !effectiveRing.metaLeadingBond) {
559
+ // Non-verbose: use Fragment for ring constructor
560
+ const baseSmiles = buildSMILES(createRingNode(
561
+ effectiveRing.atoms,
562
+ effectiveRing.size,
563
+ effectiveRing.ringNumber,
564
+ effectiveRing.offset,
565
+ {},
566
+ {},
567
+ effectiveRing.bonds,
568
+ effectiveRing.metaBranchDepths,
569
+ ));
570
+ lines.push(`${indent}const ${varName} = Fragment('${baseSmiles}');`);
571
+
572
+ let currentVar = varName;
573
+ if (Object.keys(effectiveRing.substitutions || {}).length > 0) {
574
+ const subsSmiles = getRingWithSubsSmiles(effectiveRing);
575
+ const newVar = nextVar();
576
+ lines.push(`${indent}const ${newVar} = Fragment('${subsSmiles}');`);
577
+ currentVar = newVar;
578
+ }
579
+
580
+ const {
581
+ lines: attLines, currentVar: attVar,
582
+ } = generateAttachmentCode(effectiveRing, indent, nextVar, currentVar, verbose);
583
+ lines.push(...attLines);
475
584
 
476
- const {
477
- lines: attLines, currentVar: attVar,
478
- } = generateAttachmentCode(effectiveRing, indent, nextVar, subVar);
479
- lines.push(...attLines);
585
+ ringFinalVars.push(attVar);
586
+ } else {
587
+ // Verbose: original behavior
588
+ const { optionsStr } = buildRingOptions(effectiveRing, { includeBranchDepths: true });
589
+ lines.push(`${indent}const ${varName} = Ring({ ${optionsStr} });`);
590
+
591
+ const {
592
+ lines: subLines, currentVar: subVar,
593
+ } = generateSubstitutionCode(effectiveRing, indent, nextVar, varName);
594
+ lines.push(...subLines);
595
+
596
+ const {
597
+ lines: attLines, currentVar: attVar,
598
+ } = generateAttachmentCode(effectiveRing, indent, nextVar, subVar, verbose);
599
+ lines.push(...attLines);
480
600
 
481
- ringFinalVars.push(attVar);
601
+ ringFinalVars.push(attVar);
602
+ }
482
603
  });
483
604
 
484
605
  const leadingBond = fusedRing.metaLeadingBond;
@@ -520,7 +641,7 @@ function decompileSimpleFusedRing(fusedRing, indent, nextVar) {
520
641
  * rings to a fused ring. The codegen uses this metadata to correctly interleave
521
642
  * ring markers and handle branch depths.
522
643
  */
523
- function decompileComplexFusedRing(fusedRing, indent, nextVar) {
644
+ function decompileComplexFusedRing(fusedRing, indent, nextVar, verbose = true) {
524
645
  const lines = [];
525
646
  const sequentialRings = fusedRing.metaSequentialRings || [];
526
647
  const seqAtomAttachments = fusedRing.metaSeqAtomAttachments || new Map();
@@ -528,17 +649,45 @@ function decompileComplexFusedRing(fusedRing, indent, nextVar) {
528
649
  // Step 1: Decompile the base fused ring
529
650
  const ringVars = [];
530
651
  fusedRing.rings.forEach((ring) => {
531
- const { optionsStr } = buildRingOptions(ring, { includeBranchDepths: true });
532
652
  const varName = nextVar();
533
- lines.push(`${indent}const ${varName} = Ring({ ${optionsStr} });`);
534
653
 
535
- const subResult = generateSubstitutionCode(ring, indent, nextVar, varName);
536
- lines.push(...subResult.lines);
654
+ if (!verbose && !ring.metaLeadingBond) {
655
+ const baseSmiles = buildSMILES(createRingNode(
656
+ ring.atoms,
657
+ ring.size,
658
+ ring.ringNumber,
659
+ ring.offset,
660
+ {},
661
+ {},
662
+ ring.bonds,
663
+ ring.metaBranchDepths,
664
+ ));
665
+ lines.push(`${indent}const ${varName} = Fragment('${baseSmiles}');`);
666
+
667
+ let currentVar = varName;
668
+ if (Object.keys(ring.substitutions || {}).length > 0) {
669
+ const subsSmiles = getRingWithSubsSmiles(ring);
670
+ const newVar = nextVar();
671
+ lines.push(`${indent}const ${newVar} = Fragment('${subsSmiles}');`);
672
+ currentVar = newVar;
673
+ }
674
+
675
+ const attResult = generateAttachmentCode(ring, indent, nextVar, currentVar, verbose);
676
+ lines.push(...attResult.lines);
677
+
678
+ ringVars.push({ var: attResult.currentVar, ring });
679
+ } else {
680
+ const { optionsStr } = buildRingOptions(ring, { includeBranchDepths: true });
681
+ lines.push(`${indent}const ${varName} = Ring({ ${optionsStr} });`);
537
682
 
538
- const attResult = generateAttachmentCode(ring, indent, nextVar, subResult.currentVar);
539
- lines.push(...attResult.lines);
683
+ const subResult = generateSubstitutionCode(ring, indent, nextVar, varName);
684
+ lines.push(...subResult.lines);
685
+
686
+ const attResult = generateAttachmentCode(ring, indent, nextVar, subResult.currentVar, verbose);
687
+ lines.push(...attResult.lines);
540
688
 
541
- ringVars.push({ var: attResult.currentVar, ring });
689
+ ringVars.push({ var: attResult.currentVar, ring });
690
+ }
542
691
  });
543
692
 
544
693
  // Decompile seqAtomAttachments BEFORE fuse so their vars are declared early
@@ -548,7 +697,7 @@ function decompileComplexFusedRing(fusedRing, indent, nextVar) {
548
697
  seqAtomAttachments.forEach((attachments, pos) => {
549
698
  const attVars = [];
550
699
  attachments.forEach((att) => {
551
- const attResult = decompileChildNode(att, indent, nextVar);
700
+ const attResult = decompileChildNode(att, indent, nextVar, verbose);
552
701
  lines.push(attResult.code);
553
702
  attVars.push(attResult.finalVar);
554
703
  });
@@ -670,17 +819,45 @@ function decompileComplexFusedRing(fusedRing, indent, nextVar) {
670
819
  // Decompile sequential rings
671
820
  const seqRingVars = [];
672
821
  sequentialRings.forEach((ring) => {
673
- const { optionsStr } = buildRingOptions(ring, { includeBranchDepths: true });
674
822
  const varName = nextVar();
675
- lines.push(`${indent}const ${varName} = Ring({ ${optionsStr} });`);
676
823
 
677
- const subResult = generateSubstitutionCode(ring, indent, nextVar, varName);
678
- lines.push(...subResult.lines);
824
+ if (!verbose && !ring.metaLeadingBond) {
825
+ const baseSmiles = buildSMILES(createRingNode(
826
+ ring.atoms,
827
+ ring.size,
828
+ ring.ringNumber,
829
+ ring.offset,
830
+ {},
831
+ {},
832
+ ring.bonds,
833
+ ring.metaBranchDepths,
834
+ ));
835
+ lines.push(`${indent}const ${varName} = Fragment('${baseSmiles}');`);
836
+
837
+ let currentVar = varName;
838
+ if (Object.keys(ring.substitutions || {}).length > 0) {
839
+ const subsSmiles = getRingWithSubsSmiles(ring);
840
+ const newVar = nextVar();
841
+ lines.push(`${indent}const ${newVar} = Fragment('${subsSmiles}');`);
842
+ currentVar = newVar;
843
+ }
679
844
 
680
- const attResult = generateAttachmentCode(ring, indent, nextVar, subResult.currentVar);
681
- lines.push(...attResult.lines);
845
+ const attResult = generateAttachmentCode(ring, indent, nextVar, currentVar, verbose);
846
+ lines.push(...attResult.lines);
682
847
 
683
- seqRingVars.push(attResult.currentVar);
848
+ seqRingVars.push(attResult.currentVar);
849
+ } else {
850
+ const { optionsStr } = buildRingOptions(ring, { includeBranchDepths: true });
851
+ lines.push(`${indent}const ${varName} = Ring({ ${optionsStr} });`);
852
+
853
+ const subResult = generateSubstitutionCode(ring, indent, nextVar, varName);
854
+ lines.push(...subResult.lines);
855
+
856
+ const attResult = generateAttachmentCode(ring, indent, nextVar, subResult.currentVar, verbose);
857
+ lines.push(...attResult.lines);
858
+
859
+ seqRingVars.push(attResult.currentVar);
860
+ }
684
861
  });
685
862
 
686
863
  // Decompile chain atom attachments
@@ -689,7 +866,7 @@ function decompileComplexFusedRing(fusedRing, indent, nextVar) {
689
866
  const attachments = seqAtomAttachments.get(entry.attachmentPos) || [];
690
867
  const attVars = [];
691
868
  attachments.forEach((att) => {
692
- const attResult = decompileChildNode(att, indent, nextVar);
869
+ const attResult = decompileChildNode(att, indent, nextVar, verbose);
693
870
  lines.push(attResult.code);
694
871
  attVars.push(attResult.finalVar);
695
872
  });
@@ -758,6 +935,7 @@ function decompileComplexFusedRing(fusedRing, indent, nextVar) {
758
935
  const atomValueMap = fusedRing.metaAtomValueMap || new Map();
759
936
  const bondMap = fusedRing.metaBondMap || new Map();
760
937
  const ringOrderMap = fusedRing.metaRingOrderMap;
938
+ const branchIdMap = fusedRing.metaBranchIdMap || new Map();
761
939
 
762
940
  // Helper to format a single atom entry with all available metadata
763
941
  function formatAtomEntry(pos) {
@@ -768,6 +946,9 @@ function decompileComplexFusedRing(fusedRing, indent, nextVar) {
768
946
  if (ringOrderMap && ringOrderMap.has(pos)) {
769
947
  parts.push(`rings: [${ringOrderMap.get(pos).join(', ')}]`);
770
948
  }
949
+ if (branchIdMap.has(pos) && branchIdMap.get(pos) !== null) {
950
+ parts.push(`branchId: ${branchIdMap.get(pos)}`);
951
+ }
771
952
  if (seqAtomAttachmentVarMap.has(pos)) {
772
953
  parts.push(`attachments: [${seqAtomAttachmentVarMap.get(pos).join(', ')}]`);
773
954
  }
@@ -820,24 +1001,24 @@ function decompileComplexFusedRing(fusedRing, indent, nextVar) {
820
1001
  * - Sequential rings or interleaved codegen → preserves metadata (needs it for correct SMILES)
821
1002
  * - Everything else → structural API calls only (no metadata)
822
1003
  */
823
- function decompileFusedRing(fusedRing, indent, nextVar) {
1004
+ function decompileFusedRing(fusedRing, indent, nextVar, verbose = true) {
824
1005
  const seqRings = fusedRing.metaSequentialRings;
825
1006
  const hasSeqRings = seqRings && seqRings.length > 0;
826
1007
  const isInterleaved = needsInterleavedCodegen(fusedRing);
827
1008
 
828
1009
  // Use complex decompilation for sequential rings or genuinely interleaved fused rings
829
1010
  if (hasSeqRings || isInterleaved) {
830
- return decompileComplexFusedRing(fusedRing, indent, nextVar);
1011
+ return decompileComplexFusedRing(fusedRing, indent, nextVar, verbose);
831
1012
  }
832
1013
 
833
1014
  // Everything else goes through the simple path (no metadata)
834
- return decompileSimpleFusedRing(fusedRing, indent, nextVar);
1015
+ return decompileSimpleFusedRing(fusedRing, indent, nextVar, verbose);
835
1016
  }
836
1017
 
837
1018
  /**
838
1019
  * Decompile a Molecule node
839
1020
  */
840
- function decompileMolecule(molecule, indent, nextVar) {
1021
+ function decompileMolecule(molecule, indent, nextVar, verbose = true) {
841
1022
  const lines = [];
842
1023
  const { components } = molecule;
843
1024
 
@@ -853,7 +1034,7 @@ function decompileMolecule(molecule, indent, nextVar) {
853
1034
  const componentFinalVars = [];
854
1035
  components.forEach((component) => {
855
1036
  // eslint-disable-next-line no-use-before-define
856
- const { code: componentCode, finalVar } = decompileNode(component, indent, nextVar);
1037
+ const { code: componentCode, finalVar } = decompileNode(component, indent, nextVar, verbose);
857
1038
  lines.push(componentCode);
858
1039
  // Note: metaLeadingBond is now handled in component constructors via metadata
859
1040
  // or leadingBond option, so no mutation needed here
@@ -868,21 +1049,21 @@ function decompileMolecule(molecule, indent, nextVar) {
868
1049
  return { code: lines.join('\n'), finalVar: finalVarName };
869
1050
  }
870
1051
 
871
- function decompileNode(node, indent, nextVar) {
1052
+ function decompileNode(node, indent, nextVar, verbose = true) {
872
1053
  if (isRingNode(node)) {
873
- return decompileRing(node, indent, nextVar);
1054
+ return decompileRing(node, indent, nextVar, verbose);
874
1055
  }
875
1056
 
876
1057
  if (isLinearNode(node)) {
877
- return decompileLinear(node, indent, nextVar);
1058
+ return decompileLinear(node, indent, nextVar, verbose);
878
1059
  }
879
1060
 
880
1061
  if (isFusedRingNode(node)) {
881
- return decompileFusedRing(node, indent, nextVar);
1062
+ return decompileFusedRing(node, indent, nextVar, verbose);
882
1063
  }
883
1064
 
884
1065
  if (isMoleculeNode(node)) {
885
- return decompileMolecule(node, indent, nextVar);
1066
+ return decompileMolecule(node, indent, nextVar, verbose);
886
1067
  }
887
1068
 
888
1069
  throw new Error(`Unknown node type: ${node.type}`);
@@ -894,15 +1075,19 @@ function decompileNode(node, indent, nextVar) {
894
1075
  * @param {Object} options - Options
895
1076
  * @param {number} options.indent - Indentation level (default 0)
896
1077
  * @param {string} options.varName - Variable name prefix (default 'v')
1078
+ * @param {boolean} options.verbose - Use verbose constructor syntax (default false).
1079
+ * When false, uses Fragment('SMILES') for Ring and simple Linear nodes.
897
1080
  * @param {boolean} options.includeMetadata - Include metadata assignments
898
1081
  * (default true). Set to false for cleaner output (but code may not work)
899
1082
  */
900
1083
  export function decompile(node, options = {}) {
901
- const { indent = 0, varName = 'v', includeMetadata = true } = options;
1084
+ const {
1085
+ indent = 0, varName = 'v', includeMetadata = true, verbose = false,
1086
+ } = options;
902
1087
  const indentStr = ' '.repeat(indent);
903
1088
  const nextVar = createCounter(varName);
904
1089
 
905
- const { code } = decompileNode(node, indentStr, nextVar);
1090
+ const { code } = decompileNode(node, indentStr, nextVar, verbose);
906
1091
 
907
1092
  // Always use export for declarations
908
1093
  let result = code.replace(/^(\s*)(const|let) /gm, '$1export $2 ');