
Our previous post on FaIr representation of ambiguous lipid annotation proposed a way to translate lipids expressed in shorthand notation into CXSMILES. The post was wrong in too many ways, so don’t read it. Thanks to valuable feedback, we revised the implementation. The revision follows two principles:
- Respect the existing SMILES and CXSMILES standards: don’t try to tweak standards for a different purpose.
- For features that SMILES and CXSMILES do not support, exploit the trailer that follows the closing pipes. CXSMILES tools discard this part when they rewrite a molecule, or treat it as a text string. We use it to store the missing information.
This separation is the central idea of the revised version. The chemically meaningful structure stays before and inside |...|. Chain-length constraints, unresolved sn-regioisomerism, and confidence values go after the pipes. These are expressed by self-invented tokens such as constrain(), swappable(), etc. separated by ;. The rules in a nutshell:
- trans- and cis- C=C geometry is expressed in SMILES by
/C=C/or/C=C\, resp. Unspecified geometry is indicated by a bareC=C. - Unknown double-bond positions use
Sg:groups. To declare the length constraint, we introduce aconstrain(...)token after the pipes. - Unknown modification positions such as an
-OHgroup use the standardm:position-variation feature. - Unknown sn– assignment are declared with
swappable(...)in the trailer. The molecule remains connected in the SMILES section, and each arm receive standard$snN$labels in the CXSMILES extension between the pipes. - Confidence – or any other numeric property of atoms or bonds – is expressed
dbPos(...)(for C=C) andmPos(...)for modifications.
1. The design rules in detail
The whole revision reduces to one rule: Everything the standard covers goes inside the pipes, correct and complete. Everything it does not goes after them, where no toolkit can mistake it for structure.
The part before and inside |...| is ordinary, conformant CXSMILES. A toolkit that knows nothing about lipids parses it, renders it, and gets a chemically valid molecule. Nothing lipid-specific is smuggled into a standard field, and no standard field is given a private meaning.
The part after the closing pipe is the title field. A SMILES reader treats a title as the molecule’s name, never as structure. CDK Depict prints it under the picture unless asked not to. RDKit stores it in _Name and drops it when it writes the molecule out again. Strip the trailer and what remains is still valid CXSMILES describing a chemically valid molecule. You lose the lipid semantics, not the structure.

1.1 Inside the pipes
| Encoding | Meaning |
|---|---|
bare C=C | double-bond geometry undetermined, nothing added |
Sg: | a double bond lies somewhere in this stretch |
m: | this group attaches to one of these candidate atoms |
$snN$ | this atom is the sn-N attachment point |
These are all standard CXSMILES. The only things that changed are that ctu: and f: are gone, and the chains now live in the main string instead of in grouped fragments.
1.2 After the pipes: the trailer
The trailer is a ;-separated list of name(argument) tokens. These are not standards, but our invention.
| Token | Meaning |
|---|---|
constrain(a+b=15) | the Sg: runs marked a and b span 15 carbons between them |
swappable(sn1,sn2) | the chains at these labelled positions may be exchanged |
dbPos(sn1:9@92.0) | the double bond at Δ9 on sn-1 was called with 92% confidence |
mPos(OH1:11OH@50,13OH@50) | the group on the stub labelled OH1 is at position 11 or 13, evenly split |
Two rules make the trailer safe to extend.
Tokens name things, never positions. constrain names Sg: variables. swappable, dbPos, and mPos name $...$ atom labels. A chain’s snN, or an m: stub’s own label. Toolkits maintain those labels across canonicalization, so renumbering the molecule does not rot the token. An atom index in the trailer would silently go stale the first time that happened, because nothing rewrites the title field.
Anything stated in the trailer is anchored in the pipes. A swappable, dbPos, or mPos token always comes with the $...$ labels it names, so the |...| block is never empty while the trailer has something to say. That keeps a one-character check honest: pipes mean something was undetermined.
2. Current limitations of CXSMILES
It is worth listing the constraints that pushed the design here, because each one is a thing CXSMILES genuinely cannot do.
- An
RG:definition cannot contain a nested|...|block. CDK rejects it. So a chain needing anSg:orm:block of its own could never be an R-group alternative, andPC 16:0_18:1had to choose between saying its double bond was unlocalized and saying its sn assignment was. It used to keep the double bond and drop the sn ambiguity silently. This is why chains are now always written into the main string and the sn statement moved to the trailer. - A Markush R-group states the wrong cardinality. Under the ordinary reading, one label with N definitions substitutes each of its sites independently, so
PC 16:0_18:1also admittedPC 16:0/16:0, and a TG permitted 27 assignments where the name allows 6. Labelled positions plusswappableare unambiguous. - RDKit does not implement
RG:at all, not even the minimal case. Dropping it means every string this crate emits now parses in both toolkits. Sg:nhas nowhere to record a repeat count. Henceconstrain(...).m:candidate lists cannot see insideSg:runs. A chain with both an unlocalized modification and unlocalized double bonds can only offer the atoms physically present in the string, so the candidate list understates the ambiguity. The safer direction, but still not the truth.ctu:andf:look applicable and are not.ctu:is a query feature for matching either geometry, and a bareC=Calready says as much about a structure.f:groups components into one entity and expresses and, never the or that unresolved regiochemistry needs.- No construct expresses weighted alternatives, so a 92% call and a 100% call would be written identically in the structure. The percentages are not lost: they move to
dbPosandmPostrailer tokens. - No construct varies a bond order within a structure, which is why sum compositions such as
PC 34:1are rejected rather than guessed.
3. Reference implementation
These rules are fully integrated into LipidOracle. To support testing and revision, we implemented a standalone Rust library, lipidoracle-smiles, available at https://github.com/zamboni-lab/lipidoracle-smiles.
The API includes functions to convert shorthand names into extended CXSMILES and vice versa, canonicalization, a function to obtain a better depiction, demos, and so on. If you have comments, post them there.
3.1 Limitations
Sum compositions such as PC 34:1 and PC 34:2 are still rejected by our implementation and LipidOracle. The old post explained why PC 34:2 is a genuinely harder problem than PC 34:1, because the two double bonds can be distributed as 0+2 or 1+1 across the two chains. That reasoning stands, and so does the decision not to guess a chain split.
Carbohydrate sequences such as Gal-Glc-Cer are not converted, because they do not determine the glycosidic linkage positions. A functional group on C1 of an acyl chain can change the linkage itself and is rejected.
3.2 Toolkit behavior
Both CDK and RDKit parse every string in this post. That is the point of keeping the standard inside the pipes.
The trailer is a title, and a round trip through a toolkit is lossy in a way worth knowing. The |...| block survives and is renumbered correctly, but a title is dropped when a toolkit rewrites the molecule. A string that went in carrying constrain(a+b=15) comes back with its Sg: markers intact and no length. Still well-formed, quietly less specific. Preserve the trailer yourself if a toolkit rewrites the molecule.
For depiction, we coded a smiles_for_depiction() that canonicalizes the string, reindexes every CXSMILES field, and places each m: group over a representative bond so a viewer has something deterministic to draw. The original string remains the analytical record. Renderers that ignore Sg: see only the fixed scaffold and produce a truncated chain. When a plain SMILES variable lengths. That expansion is for drawing. It does not turn an unknown position into a measurement.
4. References
- Weininger D. SMILES, a chemical language and information system. 1. Introduction to methodology and encoding rules. Journal of Chemical Information and Computer Sciences. 1988. DOI: 10.1021/ci00057a005.
- ChemAxon. Extended SMILES and SMARTS, CXSMILES and CXSMARTS. Documentation.
- Willighagen E, Rutz A, Ni Z. CDK CXSMILES documentation and lipid templates. Documentation.
- CDK Source and issues.
- Liebisch G, Fahy E, Aoki J, et al. Update on LIPID MAPS classification, nomenclature, and shorthand notation for MS-derived lipid structures. Journal of Lipid Research. 2020. DOI: 10.1194/jlr.S120001025.
5. Examples
What follows is a long list of examples. Many more can be found at https://github.com/zamboni-lab/lipidoracle-smiles/blob/master/demo.html. Every string in this section is the actual output of the converter. The depiction was obtained using CDK Depict with smiles_for_depiction().
Fully determined
FA 18:1(9Z) is fully determined, so it is plain SMILES with no pipes and no trailer:
FA 18:1(9Z) → OC(=O)CCCCCCC/C=C\CCCCCCCC
There is nothing to encode. The geometry is in the bond slashes.

Unknown double-bond position
FA 18:1 has one double bond that is somewhere in the chain but not localized. The string marks the variable stretch with Sg: and records its length in the trailer:
FA 18:1 → OC(=O)CC=CC |Sg:n:3:a:ht,Sg:n:6:b:ht| constrain(a+b=15)
The Sg:n: entries mark variable-length segments. The constrain(a+b=15) keeps the total number of carbons correct. The structure says “one double bond somewhere in an 18-carbon acyl chain”, not “a double bond at the position used by this drawing”.

Two unlocalized double bonds:
FA 18:2 → OC(=O)CC=CC=CC |Sg:n:3:a:ht,Sg:n:5:b:ht,Sg:n:8:c:ht| constrain(a+b+c=14)

Four:
FA 20:4 → OC(=O)CC=CC=CC=CC=CC |Sg:n:3:a:ht,Sg:n:5:b:ht,Sg:n:7:c:ht,Sg:n:9:d:ht,Sg:n:12:e:ht| constrain(a+b+c+d+e=14)
The pattern is simple. A chain with N unlocalized double bonds needs N+1 variable regions. The sum constraint preserves the chain length.

The same idea works inside a connected lipid. PC 16:0/18:2 has resolved sn positions and a resolved chain split, but the double bonds in the 18:2 chain are not localized:
PC 16:0/18:2 → C(COP(=O)([O-])OCC[N+](C)(C)C)(OC(=O)CC=CC=CC)COC(=O)CCCCCCCCCCCCCCC |Sg:n:16:a:ht,Sg:n:18:b:ht,Sg:n:21:c:ht| constrain(a+b+c=14)

Unknown modification position
FA 18:0;OH has a hydroxyl that is present but not placed. It is a disconnected stub whose m: block lists every carbon it could sit on:
FA 18:0;OH → OC(=O)CCCCCCCCCCCCCCCCC.*O |m:20:3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19|
The modification is an attachment ambiguity, not a variable-length stretch, so it is not an Sg:.

Unresolved sn assignment
PC 16:0_18:1(9) has both chains and the double-bond position known, but not which chain is on sn-1 and which on sn-2. Both chains are in the main string. Each linking atom carries an snN label, and the trailer says they can be swapped:
PC 16:0_18:1(9) → C(COP(=O)([O-])OCC[N+](C)(C)C)(OC(=O)CCCCCCCC=CCCCCCCCC)COC(=O)CCCCCCCCCCCCCCC |$;;;;;;;;;;;;;sn2;;;;;;;;;;;;;;;;;;;;;sn1$| swappable(sn1,sn2)

The case that motivated the whole design
Now the hard one. PC 16:0_18:1 has both an unlocalized double bond in the 18:1 chain and an unresolved sn assignment. The old f: encoding could not state both at once. The new one can, because the chains are in the main string and the two statements live in different places:
PC 16:0_18:1 → C(COP(=O)([O-])OCC[N+](C)(C)C)(OC(=O)CC=CC)COC(=O)CCCCCCCCCCCCCCC |$;;;;;;;;;;;;;sn2;;;;;;;;sn1$,Sg:n:16:a:ht,Sg:n:19:b:ht| swappable(sn1,sn2);constrain(a+b=15)
Two things worth noting. Each chain gets its own equation, and equations are matched to Sg: markers positionally, in emission order, not by variable name. Variable letters restart per chain and can repeat. A fully determined chain contributes no equation at all.

The same machinery handles a glycerolipid with everything at once. DG 18:1(9);OH(5)_18:1;OH has an unresolved sn assignment, one chain with a localized double bond and a localized hydroxyl, and the other chain with an unlocalized double bond and an unlocalized hydroxyl:
DG 18:1(9);OH(5)_18:1;OH
→ C(CO)(OC(=O)CC=CC.*O)COC(=O)CCCC(O)CCCC=CCCCCCCCC |$;;;sn2;;;;;;;;;;sn1$,Sg:n:6:a:ht,Sg:n:9:b:ht,m:10:6.7.8.9| swappable(sn1,sn2);constrain(a+b=15)
This one string says several things at once: the chains may be swapped (swappable), one chain has an unlocalized double bond (Sg:), the other has an unlocalized hydroxyl (m:), and the variable stretch spans 15 carbons (constrain).

A few of the more demanding examples:
TG 18:1(9);5OH_18:2;9Ep_18:1
→ C(COC(=O)CC=CC)(OC(=O)CCCCCCCC%10OC%10CC=CC=CC)COC(=O)CCCC(O)CCCC=CCCCCCCCC |$;;sn3;;;;;;;sn2;;;;;;;;;;;;;;;;;;;;sn1$,Sg:n:5:a:ht,Sg:n:8:b:ht,Sg:n:22:c:ht,Sg:n:24:d:ht,Sg:n:27:e:ht| swappable(sn1,sn2,sn3);constrain(a+b=15);constrain(c+d+e=5)

PC O-16:1_18:2;OH
→ C(COP(=O)([O-])OCC[N+](C)(C)C)(OC(=O)CC=CC=CC.*O)COCCC=CC |$;;;;;;;;;;;;;sn2;;;;;;;;;;;;sn1$,Sg:n:16:a:ht,Sg:n:18:b:ht,Sg:n:21:c:ht,Sg:n:27:d:ht,Sg:n:30:e:ht,m:22:16.17.18.19.20.21| swappable(sn1,sn2);constrain(a+b+c=14);constrain(d+e=13)

Cer d18:1(4)/16:1;OH → C(CO)(NC(=O)CC=CC.*O)C(O)C=CCCCCCCCCCCCCC |Sg:n:6:a:ht,Sg:n:9:b:ht,m:10:6.7.8.9| constrain(a+b=13)

Three chains
The framework scales to triacylglycerols. TG 18:0_18:1_18:2:
TG 18:0_18:1_18:2 → C(COC(=O)CC=CC=CC)(OC(=O)CC=CC)COC(=O)CCCCCCCCCCCCCCCCC |$;;sn3;;;;;;;;;sn2;;;;;;;;sn1$,Sg:n:5:a:ht,Sg:n:7:b:ht,Sg:n:10:c:ht,Sg:n:14:d:ht,Sg:n:17:e:ht| swappable(sn1,sn2,sn3);constrain(a+b+c=14);constrain(d+e=15)
The two constraints are separate because they belong to different chains. a+b+c=14 is the 18:2 chain, d+e=15 is the 18:1 chain.

Expressing partial confidence in the position of a modification
FA 20:4;OH [OH sn1: 11 50%, 13 50%]
→ OC(=O)CC=CC=CC=CC=CC.*O |$;sn1;;;;;;;;;;;;OH1$,Sg:n:3:a:ht,Sg:n:5:b:ht,Sg:n:7:c:ht,Sg:n:9:d:ht,Sg:n:12:e:ht,m:13:3.4.5.6.7.8.9.10.11.12| constrain(a+b+c+d+e=14);mPos(OH1:11OH@50,13OH@50)
