asciichem 0.31.3 → 0.31.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ab5305dc2ec00320b62efbfae105108d23c90b6f5704c64e0f2ef59ec8c85983
4
- data.tar.gz: 7bee58c136c5590646a736c42b900c4c4322fc7beeefcc9980b4ca5bfae6c4b8
3
+ metadata.gz: c9c3f297f95612987dbe2a4e7f15bcaa0d4628dbae0d074dce5aeea2fb5e0efa
4
+ data.tar.gz: 75a1ef4ea31451fccfb37369299d86afa1a08b26c825054de2eda109c636a561
5
5
  SHA512:
6
- metadata.gz: a59410198a58a61ff195ed6ca2e4a5a7d20d751af9dc8690e1d38405e68a81ff3470ac532c9cd4517ed470fe814dab34db1221357a09ab63919bab78b57795b4
7
- data.tar.gz: e1da749ccc43b3b1e00db74640e761b249d2445159026729780c11fa8b54d18c6c9d51f0655317229a9cd35cc1ffa727477f0c5be9033438be25fb8388acfa35
6
+ metadata.gz: cba5c440d3da6c3a436b207d36203f07641ad1b76c61ade69ac5e39697c1b79a81374f7e7106a5764611b3f400f28f9dae89a7054a70c4124b6c21da0d6f8c8a
7
+ data.tar.gz: e7cb123f63497d962297ea6629e684a0b8af6417c494a7332e02a9729585e977a0c02e2acdd6ee7eb99868fa70475b9a8e57e748eb38fde789957c66a8dc2afa
data/CHANGELOG.md CHANGED
@@ -3,6 +3,41 @@
3
3
  All notable changes to AsciiChem are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.31.4] - 2026-10-20
7
+
8
+ ### Changed
9
+ - Canonical SMILES now consumes the perception pass (item 2): atoms
10
+ in perceived aromatic rings are written lowercase with implicit ring
11
+ bonds, so Kekulé input canonicalizes — `C1=CC=CC=C1` → `c1ccccc1`,
12
+ aspirin → `CC(=O)Oc1ccccc1C(=O)O`. Canonical-form stability is a
13
+ fixed point (`write(parse(write(x))) == write(x)`); composition and
14
+ connectivity are preserved. Perception's sp2 rule now requires the
15
+ double-bond partner to itself be in a ring, keeping quinone rings
16
+ Kekulé — the guard that makes aromatic canonicalization chemically
17
+ safe. Corpus contract 0.7.0 (`canonicalSmiles` fixture field).
18
+ - `Molecule#stoichiometric_coefficient` is the single coefficient
19
+ rule shared by `Reaction#stoichiometry`, `BalanceCheck`, and
20
+ `element_counts`.
21
+
22
+ ### Added
23
+ - Mechanism electron-flow markers (item 4, slice 4c): a step value may
24
+ carry `| flow: SOURCE > TARGET` clauses (semicolon-separated),
25
+ typed as `Mechanism::Flow`, carried by `MechanismGraph`, emitted on
26
+ the wire (`MechanismStep.flows`, asciichem-model v1 additive), and
27
+ re-emitted in canonical text. Malformed clauses raise; nothing is
28
+ dropped.
29
+ - Depiction backends (item 7): `AsciiChem::Layout.backend` lets a
30
+ depiction engine (RDKit, CDK, a Rust layout core) replace the
31
+ built-in elkrb pipeline without touching the renderer or the model.
32
+
33
+ ### Fixed
34
+ - Mechanism wire emission: the adapter passed raw step/spectator
35
+ strings to the reaction serializer, so `Mechanism#to_model_json`
36
+ had never worked. Steps and spectators are now parsed with the same
37
+ grammar; spectators that are not valid AsciiChem raise clearly.
38
+ - Test isolation: the CLI `--engine parsanol` spec leaked the engine
39
+ selection into later examples.
40
+
6
41
  ## [0.31.3] - 2026-10-20
7
42
 
8
43
  ### Added
data/README.adoc CHANGED
@@ -45,6 +45,28 @@ formula.to_mathml # => "<math>...</math>"
45
45
  formula.to_text # => "H_2O" (round trip)
46
46
  ----
47
47
 
48
+ == Conformance
49
+
50
+ The reference implementation of https://www.asciichem.org[the AsciiChem
51
+ contract]. Every claim is gated by the shared corpus
52
+ (https://github.com/asciichem/asciichem-tests[asciichem-tests]) in CI —
53
+ a level without the green corpus run behind it does not ship.
54
+
55
+ [cols="1,1,4"]
56
+ |===
57
+ |Level |Claim |Basis
58
+
59
+ |L0 notation |yes |parse/reject contract; every `roundTrip` fixture returns its input
60
+ |L1 semantics |yes |wire form validates against the v1 schemas and round-trips; the perception pass (implicit hydrogens, aromatic rings) is corpus-gated
61
+ |L2 render |yes |MathML byte-goldens, canonical text, and the wire form for every construct
62
+ |L3 stereo |yes |tetrahedral chirality, directional bonds, wedge/hash; parity-correct canonical SMILES including ring closures
63
+ |L4 identity |partial |opt-in InChI engine with stereo layers, OPSIN adapter, identifier cross-check, citation bibitems; CAS source decision pending
64
+ |===
65
+
66
+ TypeScript and Python claim the same levels they pass; see their
67
+ READMEs and the https://www.asciichem.org/guides/conformance-levels/[levels
68
+ document].
69
+
48
70
  == Design
49
71
 
50
72
  See link:ARCHITECTURE.adoc[] and the specification at
@@ -199,13 +199,23 @@ module AsciiChem
199
199
  def visit_mechanism(mech)
200
200
  parts = ["mechanism"]
201
201
  unless mech.steps.empty? && mech.spectators.empty?
202
- lines = mech.steps.map { |s| "#{s.label}: #{s.reaction}" }
202
+ lines = mech.steps.map { |s| "#{s.label}: #{mechanism_step_text(s)}" }
203
203
  mech.spectators.each { |sp| lines << "spectator: #{sp}" }
204
204
  parts << "{\n #{lines.join("\n ")}\n}"
205
205
  end
206
206
  parts.join
207
207
  end
208
208
 
209
+ # Reaction plus its electron-flow clauses in canonical form:
210
+ # ` | flow: SRC > DST; SRC2 > DST2`.
211
+ def mechanism_step_text(step)
212
+ flows = step.flows
213
+ return step.reaction if flows.nil? || flows.empty?
214
+
215
+ arrows = flows.map { |f| "#{f.source} > #{f.target}" }.join('; ')
216
+ "#{step.reaction} | flow: #{arrows}"
217
+ end
218
+
209
219
  def visit_opaque_cml(opaque)
210
220
  %(<!-- opaque: #{opaque.element_name} -->)
211
221
  end
@@ -46,13 +46,40 @@ module AsciiChem
46
46
  Result.new(atoms: [], bonds: [], width: 0.0, height: 0.0)
47
47
  end
48
48
 
49
- # Compute 2D positions for a molecule's atoms and bonds.
50
- # Returns a `Layout::Result`. Algorithm defaults to `layered`
49
+ # The built-in backend: elkrb layered layout (or authored
50
+ # coordinates when present).
51
+ class BuiltinBackend
52
+ def layout(molecule, algorithm: 'layered')
53
+ Layout.builtin_layout(molecule, algorithm: algorithm)
54
+ end
55
+ end
56
+
57
+ class << self
58
+ # Depictions are views: a backend maps a molecule to a
59
+ # Layout::Result and the renderer never learns which engine
60
+ # placed the atoms. The built-in elkrb backend is the default;
61
+ # external backends (RDKit, CDK, a Rust layout core) plug in
62
+ # with `AsciiChem::Layout.backend = backend`, where backend
63
+ # responds to `layout(molecule, algorithm:)`.
64
+ attr_writer :backend
65
+
66
+ def backend
67
+ @backend ||= BuiltinBackend.new
68
+ end
69
+
70
+ # Compute 2D positions through the configured backend.
71
+ def layout(molecule, algorithm: 'layered')
72
+ backend.layout(molecule, algorithm: algorithm)
73
+ end
74
+ end
75
+
76
+ # Compute 2D positions for a molecule's atoms and bonds with the
77
+ # built-in elkrb pipeline. Algorithm defaults to `layered`
51
78
  # (Sugiyama-style hierarchical) which is deterministic across
52
79
  # runs — essential for visual regression testing. Pass
53
80
  # `algorithm: "force"` for organic-looking layouts, with the
54
81
  # caveat that output may vary between runs.
55
- def self.layout(molecule, algorithm: 'layered')
82
+ def self.builtin_layout(molecule, algorithm: 'layered')
56
83
  walker = MoleculeWalker.new(molecule)
57
84
  walk = walker.walk
58
85
  return empty_result if walk.atoms.empty?
@@ -12,7 +12,7 @@ module AsciiChem
12
12
  # a reaction (prose, "proton transfer") keeps a nil reaction and
13
13
  # takes part in no links — never an error.
14
14
  module MechanismGraph
15
- Step = Struct.new(:label, :reaction, :catalysts, keyword_init: true)
15
+ Step = Struct.new(:label, :reaction, :catalysts, :flows, keyword_init: true)
16
16
  Link = Struct.new(:from, :to, :species, keyword_init: true)
17
17
  Graph = Struct.new(:steps, :links, keyword_init: true)
18
18
 
@@ -27,7 +27,8 @@ module AsciiChem
27
27
  reaction = parse_reaction(step.reaction)
28
28
  sides = sides_of(reaction)
29
29
  catalysts = sides ? sides[:reactants].keys & sides[:products].keys : []
30
- Step.new(label: step.label, reaction: reaction, catalysts: catalysts)
30
+ Step.new(label: step.label, reaction: reaction, catalysts: catalysts,
31
+ flows: step.flows || [])
31
32
  end
32
33
 
33
34
  def links(steps)
@@ -7,12 +7,21 @@ module AsciiChem
7
7
  # Syntax:
8
8
  # mechanism{
9
9
  # step1: A + B -> C
10
- # step2: C -> D + E
10
+ # step2: C -> D + E | flow: C > D; C-B > D
11
11
  # spectator: Na+
12
12
  # }
13
+ #
14
+ # A step's value is its reaction, optionally followed by
15
+ # `| flow: SOURCE > TARGET` clauses (semicolon-separated) naming
16
+ # the electron-flow arrows drawn for that step. Sources/targets
17
+ # stay verbatim strings: an atom-level binding needs references
18
+ # the mechanism text does not yet carry.
13
19
  class Mechanism < Node
20
+ # One electron-flow marker within a step.
21
+ Flow = Struct.new(:source, :target, keyword_init: true)
22
+
14
23
  # A single step in the mechanism, identified by a label.
15
- Step = Struct.new(:label, :reaction, keyword_init: true)
24
+ Step = Struct.new(:label, :reaction, :flows, keyword_init: true)
16
25
 
17
26
  attr_accessor :steps, :spectators
18
27
 
@@ -91,23 +91,33 @@ module AsciiChem
91
91
  .select { |deficit| deficit >= 0 && deficit.modulo(1).zero? }
92
92
  end
93
93
 
94
- # Six-membered cycles where every atom is sp2 (participates in a
95
- # double bond) or aromatic-flagged — the practical fused-system
96
- # rule: per-ring strict alternation can never see both rings of
97
- # naphthalene (the shared bond is single), but per-atom sp2 does.
98
- # Returns arrays of atom indexes, one per perceived ring, or an
99
- # empty list. Full Hückel pi-electron counting is a later slice.
94
+ # Six-membered cycles where every atom is sp2 — participates in a
95
+ # double bond to an atom that is itself in a ring, or is
96
+ # aromatic-flagged. The partner-in-a-ring requirement keeps
97
+ # quinones out: their carbons are sp2, but the double bonds that
98
+ # make them so point at exocyclic oxygens, and writing such a
99
+ # ring as aromatic SMILES would state the wrong bond orders. A
100
+ # fused system still qualifies — naphthalene's fusion carbons
101
+ # double-bond to atoms of the neighbouring ring. Per-ring strict
102
+ # alternation can never see both rings (the shared bond is
103
+ # single); per-atom sp2-in-the-system does. Full Hückel
104
+ # pi-electron counting is a later slice.
100
105
  def aromatic_rings(atoms, edges)
101
106
  cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
102
- .select { |cycle| cycle.length == 6 }
103
- cycles.select { |cycle| cycle.all? { |i| sp2?(i, atoms, edges) } }
107
+ cyclic_atoms = cycles.flatten.uniq
108
+ six = cycles.select { |cycle| cycle.length == 6 }
109
+ six.select { |cycle| cycle.all? { |i| sp2?(i, atoms, edges, cyclic_atoms) } }
104
110
  end
105
111
 
106
- def sp2?(atom_index, atoms, edges)
112
+ def sp2?(atom_index, atoms, edges, cyclic_atoms)
107
113
  return true if atoms[atom_index].aromatic == true
108
114
 
109
115
  edges.any? do |edge|
110
- (edge.from == atom_index || edge.to == atom_index) && edge.kind == :double
116
+ next false unless edge.from == atom_index || edge.to == atom_index
117
+ next false unless edge.kind == :double
118
+
119
+ partner = edge.from == atom_index ? edge.to : edge.from
120
+ cyclic_atoms.include?(partner)
111
121
  end
112
122
  end
113
123
 
@@ -36,6 +36,7 @@ module AsciiChem
36
36
  raise ParseError, 'molecule has no bonds — a formula is not a structure' if edges.empty? && atoms.length > 1
37
37
 
38
38
  @atoms = atoms
39
+ perceive_aromaticity(atoms, edges)
39
40
  build_adjacency(edges)
40
41
  @visited = {}
41
42
  @digits = Array.new(atoms.length) { [] }
@@ -66,6 +67,35 @@ module AsciiChem
66
67
 
67
68
  private
68
69
 
70
+ # The canonicalizer consumes the perception pass (TODO.v2/13
71
+ # item 2): atoms in perceived aromatic rings are written as
72
+ # aromatic (lowercase) and the ring bonds between them carry no
73
+ # bond symbol — Kekulé benzene canonicalizes to `c1ccccc1`, so
74
+ # both spellings converge. Perception's in-ring double-bond
75
+ # rule keeps quinone-like rings out.
76
+ def perceive_aromaticity(atoms, edges)
77
+ @perceived_aromatic = {}
78
+ @perceived_ring_edges = {}
79
+ AsciiChem::Perception.aromatic_rings(atoms, edges).each do |ring|
80
+ ring.each { |index| @perceived_aromatic[index] = true }
81
+ ring.each_with_index do |atom, i|
82
+ other = ring[(i + 1) % ring.length]
83
+ @perceived_ring_edges[[atom, other].minmax] = true
84
+ end
85
+ end
86
+ end
87
+
88
+ # Aromatic for WRITING: the source flagged the atom, or
89
+ # perception found it in an aromatic ring.
90
+ def aromatic?(index)
91
+ @atoms[index].aromatic == true || @perceived_aromatic.key?(index)
92
+ end
93
+
94
+ def perceived_ring_bond?(from, to)
95
+ @perceived_ring_edges.key?([from, to].minmax) &&
96
+ aromatic?(from) && aromatic?(to)
97
+ end
98
+
69
99
  def build_adjacency(edges)
70
100
  @adjacency = Array.new(@atoms.length) { {} }
71
101
  @directions = {}
@@ -205,6 +235,11 @@ module AsciiChem
205
235
  token = BOND_TOKENS[kind]
206
236
  raise ParseError, "#{kind} bonds have no SMILES form (v1 subset)" unless token
207
237
 
238
+ # A bond inside a perceived aromatic ring is implied by the
239
+ # lowercase atoms: writing `=`/`-` there would state Kekulé
240
+ # alternation the canonical form does not carry.
241
+ return '' if perceived_ring_bond?(from, to)
242
+
208
243
  return token if kind != :single && kind != :aromatic
209
244
 
210
245
  if kind == :single
@@ -232,7 +267,7 @@ module AsciiChem
232
267
  return @plain_tokens[index] if !stereo && @plain_tokens[index]
233
268
 
234
269
  atom = @atoms[index]
235
- aromatic = atom.aromatic == true
270
+ aromatic = aromatic?(index)
236
271
  lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
237
272
  needs_bracket =
238
273
  atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
@@ -271,10 +306,6 @@ module AsciiChem
271
306
 
272
307
  "#{sign}#{count}"
273
308
  end
274
-
275
- def aromatic?(index)
276
- @atoms[index].aromatic == true
277
- end
278
309
  end
279
310
  end
280
311
  end
@@ -441,13 +441,44 @@ module AsciiChem
441
441
  if key == 'spectator'
442
442
  spectators.concat(value.split(/\s+/).map(&:strip))
443
443
  else
444
- steps << Model::Mechanism::Step.new(label: key, reaction: value)
444
+ reaction, flows = split_flows(key, value)
445
+ steps << Model::Mechanism::Step.new(label: key, reaction: reaction, flows: flows)
445
446
  end
446
447
  end
447
448
  Model::Mechanism.new(steps: steps, spectators: spectators)
448
449
  end
449
-
450
+
450
451
  private
452
+
453
+ # A step value is its reaction, optionally followed by
454
+ # `| flow: SOURCE > TARGET` clauses (semicolon-separated).
455
+ # Unknown clause kinds are rejected, never dropped.
456
+ def split_flows(label, value)
457
+ clauses = value.split('|').map(&:strip)
458
+ reaction = clauses.shift
459
+ flows = clauses.flat_map { |clause| parse_flow_clause(label, clause) }
460
+ [reaction, flows]
461
+ end
462
+
463
+ def parse_flow_clause(label, clause)
464
+ kind, payload = clause.split(':', 2)
465
+ unless kind&.strip == 'flow' && payload
466
+ raise AsciiChem::ParseError,
467
+ "mechanism step #{label.inspect} has an unknown clause: #{clause.inspect} " \
468
+ '(supported: "flow: SOURCE > TARGET")'
469
+ end
470
+
471
+ payload.split(';').map(&:strip).reject(&:empty?).map do |arrow|
472
+ source, target = arrow.split('>', 2).map(&:strip)
473
+ if source.nil? || target.nil? || source.empty? || target.empty?
474
+ raise AsciiChem::ParseError,
475
+ "mechanism step #{label.inspect} has a malformed flow: #{arrow.inspect} " \
476
+ '(expected "SOURCE > TARGET")'
477
+ end
478
+
479
+ Model::Mechanism::Flow.new(source: source, target: target)
480
+ end
481
+ end
451
482
 
452
483
  def parse_entries(str)
453
484
  return [] unless str
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.31.3"
4
+ VERSION = "0.31.4"
5
5
  end
@@ -6,12 +6,23 @@ module AsciiChem
6
6
  # zmatrix, calculation. Emission covers the full v1 schemas;
7
7
  # ingestion (from_wire) is currently implemented for the core
8
8
  # chemistry set only — see WireAdapter.
9
+ class MechanismFlow < Lutaml::Model::Serializable
10
+ attribute :source, :string
11
+ attribute :target, :string
12
+ json do
13
+ map "source", to: :source
14
+ map "target", to: :target
15
+ end
16
+ end
17
+
9
18
  class MechanismStep < Lutaml::Model::Serializable
10
19
  attribute :label, :string
11
20
  attribute :reaction, "AsciiChem::Wire::Reaction"
21
+ attribute :flows, MechanismFlow, collection: true
12
22
  json do
13
23
  map "label", to: :label
14
24
  map "reaction", to: :reaction
25
+ map "flows", to: :flows
15
26
  end
16
27
  end
17
28
 
@@ -236,13 +236,46 @@ module AsciiChem
236
236
  def mechanism_to_wire(node)
237
237
  AsciiChem::Wire::Mechanism.new(
238
238
  type: "mechanism",
239
- steps: node.steps.map do |step|
240
- AsciiChem::Wire::MechanismStep.new(label: step.label, reaction: reaction_to_wire(step.reaction))
241
- end,
242
- spectators: node.spectators.map { |m| molecule_to_wire(m) }
239
+ steps: node.steps.map { |step| mechanism_step_to_wire(step) },
240
+ spectators: node.spectators.map { |sp| mechanism_spectator_to_wire(sp) }
241
+ )
242
+ end
243
+
244
+ # Spectators are verbatim strings in the model (the mechanism
245
+ # body is text); the wire wants molecules, so the same grammar
246
+ # parses them here — a spectator that is not valid AsciiChem
247
+ # cannot reach the wire and says so.
248
+ def mechanism_spectator_to_wire(text)
249
+ molecule_to_wire(AsciiChem.parse(text).nodes.first)
250
+ rescue AsciiChem::ParseError
251
+ raise AsciiChem::ParseError,
252
+ "mechanism spectator is not valid AsciiChem: #{text.inspect}"
253
+ end
254
+
255
+ def mechanism_step_to_wire(step)
256
+ AsciiChem::Wire::MechanismStep.new(
257
+ label: step.label,
258
+ reaction: mechanism_reaction_to_wire(step.reaction),
259
+ flows: (step.flows || []).map do |flow|
260
+ AsciiChem::Wire::MechanismFlow.new(source: flow.source, target: flow.target)
261
+ end
243
262
  )
244
263
  end
245
264
 
265
+ # The model keeps a step's reaction as TEXT (the mechanism body
266
+ # is verbatim); the wire carries the typed node, so the same
267
+ # grammar parses it here.
268
+ def mechanism_reaction_to_wire(text)
269
+ node = AsciiChem.parse(text).nodes.first
270
+ case node
271
+ when AsciiChem::Model::Reaction then reaction_to_wire(node)
272
+ when AsciiChem::Model::ReactionCascade then cascade_to_wire(node)
273
+ else
274
+ raise AsciiChem::ParseError,
275
+ "mechanism step is not a reaction: #{text.inspect}"
276
+ end
277
+ end
278
+
246
279
  def spectrum_to_wire(node)
247
280
  AsciiChem::Wire::Spectrum.new(
248
281
  type: "spectrum",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asciichem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.31.3
4
+ version: 0.31.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.