asciichem 0.31.2 → 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: b221962f73e48a95f8d177ffd94fdf051b135f70d014b9b42aaa5d7fb1005840
4
- data.tar.gz: b2b40147701ba90d0b8b9ed014202836fbc65cbb22f14fa98f3f1195cdd3b7bd
3
+ metadata.gz: c9c3f297f95612987dbe2a4e7f15bcaa0d4628dbae0d074dce5aeea2fb5e0efa
4
+ data.tar.gz: 75a1ef4ea31451fccfb37369299d86afa1a08b26c825054de2eda109c636a561
5
5
  SHA512:
6
- metadata.gz: 75aa3b257cb79d94a5fb7308b89b4a9ce1fb827dfbe073e96723fdfbe8c8f8991ef1e37d0a0af44b4c8e52c318811f63baf064cb2db646995420f6733e3114da
7
- data.tar.gz: a33d70d38648147044a6eedf5a3e0afe171331862a7f08ea561ee52e3438bb3d3750636b85a2cbfd1d4a06e16937bb57f60616ee2a10f415ab479764741ef066
6
+ metadata.gz: cba5c440d3da6c3a436b207d36203f07641ad1b76c61ade69ac5e39697c1b79a81374f7e7106a5764611b3f400f28f9dae89a7054a70c4124b6c21da0d6f8c8a
7
+ data.tar.gz: e7cb123f63497d962297ea6629e684a0b8af6417c494a7332e02a9729585e977a0c02e2acdd6ee7eb99868fa70475b9a8e57e748eb38fde789957c66a8dc2afa
data/CHANGELOG.md CHANGED
@@ -3,6 +3,79 @@
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
+
41
+ ## [0.31.3] - 2026-10-20
42
+
43
+ ### Added
44
+ - Perception in the shared corpus (TODO.v2/13 item 2, slice C):
45
+ implicit hydrogens order aromatic bonds at 1.5 (Hückel) and declare
46
+ a value only when a default valence fits integrally — so
47
+ `c1ccccc1` and `C1=CC=CC=C1` perceive identically, and
48
+ aromatic-schematic fusion atoms decline rather than invent. Gated
49
+ by `fixtures/perception.json` in all three implementations.
50
+ - Structured condition entries (item 4, slice 1):
51
+ `Conditions#entries` derives typed `cat:`/`solv:`/`reag:` entries
52
+ (semicolon-separated) from the verbatim bracket text; free text and
53
+ unknown prefixes stay lossless, and the wire form is unchanged.
54
+ `ReactionRoles` prefers explicit roles over the above/below
55
+ heuristic.
56
+ - Reaction stoichiometry as model data (slice 2):
57
+ `Reaction#stoichiometry` and `Molecule#stoichiometric_coefficient`
58
+ — one coefficient rule shared by the reaction, `BalanceCheck`, and
59
+ composition counting.
60
+ - Mechanism step graph (slice 4a): `MechanismGraph.for` derives
61
+ parsed steps, catalysts (species on both sides), and inter-step
62
+ links from the verbatim `mechanism{}` entries.
63
+ - Reaction identity via RInChI (slice 4b): `Rxn::Writer` emits MDL
64
+ RXN V2000 and `Rinchi::BinaryEngine` wraps the opt-in `rinchi-1`
65
+ binary (install guidance, `EngineMissingError`, nothing vendored).
66
+ - Molfile stereo (L3 slice 6): `Atom#chirality` crosses as MDL atom
67
+ parity and stated hydrogens as the H-count field, so the identity
68
+ pipe derives stereoisomer-distinct InChIs; `IdentityCrossCheck`
69
+ covers stereo-layer mismatches.
70
+
71
+ ### Fixed
72
+ - SMILES writer ring-closure emission (#103): a closure-bearing
73
+ child continues the chain, so the digit lands at the end of the
74
+ traversal — `parse(write(parse(s))) == parse(s)` and writer
75
+ stability now hold for ring structures such as `C1=CCC(C)C(C)C1`.
76
+ - SMILES writer rendered stated zero hydrogens as a bare `H` (one);
77
+ it now emits `H0`.
78
+
6
79
  ## [0.31.2] - 2026-10-20
7
80
 
8
81
  ### 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?
@@ -51,11 +51,10 @@ module AsciiChem
51
51
  end
52
52
 
53
53
  def count_molecule(molecule)
54
- coefficient = molecule.coefficient.to_i
55
- coefficient = 1 if coefficient.zero?
56
54
  raw = enumerate(molecule, 1)
57
55
  return nil if raw.nil?
58
56
 
57
+ coefficient = molecule.stoichiometric_coefficient
59
58
  raw.transform_values { |v| v * coefficient }
60
59
  end
61
60
 
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # The mechanism step graph (TODO.v2/13 item 4): `mechanism{}` entries
5
+ # stay verbatim (label + reaction text) in the model; this service
6
+ # derives the graph the text implies — each step's reaction parsed
7
+ # with the same grammar, species that appear on both sides of a step
8
+ # recognised as catalysts, and steps linked where a product species
9
+ # of an earlier step is a reactant of a later one.
10
+ #
11
+ # Everything is computed; nothing mutates. A step whose value is not
12
+ # a reaction (prose, "proton transfer") keeps a nil reaction and
13
+ # takes part in no links — never an error.
14
+ module MechanismGraph
15
+ Step = Struct.new(:label, :reaction, :catalysts, :flows, keyword_init: true)
16
+ Link = Struct.new(:from, :to, :species, keyword_init: true)
17
+ Graph = Struct.new(:steps, :links, keyword_init: true)
18
+
19
+ module_function
20
+
21
+ def for(mechanism)
22
+ steps = mechanism.steps.map { |step| graph_step(step) }
23
+ Graph.new(steps: steps, links: links(steps))
24
+ end
25
+
26
+ def graph_step(step)
27
+ reaction = parse_reaction(step.reaction)
28
+ sides = sides_of(reaction)
29
+ catalysts = sides ? sides[:reactants].keys & sides[:products].keys : []
30
+ Step.new(label: step.label, reaction: reaction, catalysts: catalysts,
31
+ flows: step.flows || [])
32
+ end
33
+
34
+ def links(steps)
35
+ steps.each_with_index.flat_map do |from, i|
36
+ next [] unless from.reaction
37
+
38
+ from_products = sides_of(from.reaction)[:products].keys
39
+ steps[(i + 1)..].filter_map { |to| link_between(from, to, from_products) }
40
+ end
41
+ end
42
+
43
+ def link_between(from, to, from_products)
44
+ return nil unless to.reaction
45
+
46
+ shared = from_products & sides_of(to.reaction)[:reactants].keys
47
+ Link.new(from: from.label, to: to.label, species: shared) unless shared.empty?
48
+ end
49
+
50
+ def parse_reaction(text)
51
+ node = AsciiChem.parse(text).nodes.first
52
+ node if node.is_a?(AsciiChem::Model::Reaction) ||
53
+ node.is_a?(AsciiChem::Model::ReactionCascade)
54
+ rescue AsciiChem::ParseError
55
+ nil
56
+ end
57
+
58
+ # Coefficient-free species names per side; a cascade's entry side
59
+ # is its head and its exit side is its last step.
60
+ def sides_of(reaction)
61
+ case reaction
62
+ when AsciiChem::Model::Reaction
63
+ reaction.stoichiometry
64
+ when AsciiChem::Model::ReactionCascade
65
+ steps = reaction.steps
66
+ return nil if steps.empty?
67
+
68
+ { reactants: steps.first.stoichiometry[:reactants],
69
+ products: steps.last.stoichiometry[:products] }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -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
 
@@ -124,15 +124,21 @@ module AsciiChem
124
124
  # describe the substance, not the stoichiometric multiplier.
125
125
  def element_counts(with_coefficient: true)
126
126
  tally = Hash.new(0)
127
- multiplier = 1
128
- if with_coefficient
129
- own = coefficient&.to_i
130
- multiplier = own if own && own.positive?
131
- end
127
+ multiplier = with_coefficient ? stoichiometric_coefficient : 1
132
128
  nodes.each { |node| tally_element(node, tally, multiplier) }
133
129
  tally
134
130
  end
135
131
 
132
+ # The integer stoichiometric coefficient: the authored leading
133
+ # number, or 1 when absent or not positive. Single source for
134
+ # every consumer (reaction stoichiometry, the balance check,
135
+ # composition) — coefficients are model data, not per-caller
136
+ # parsing.
137
+ def stoichiometric_coefficient
138
+ own = coefficient&.to_i
139
+ own && own.positive? ? own : 1
140
+ end
141
+
136
142
  private
137
143
 
138
144
  def tally_element(node, tally, multiplier)
@@ -5,8 +5,49 @@ module AsciiChem
5
5
  # A chemical reaction: reactants, an arrow, and products. Conditions
6
6
  # render above and below the arrow per IUPAC.
7
7
  class Reaction < Node
8
+ # Conditions carry the bracket text verbatim (the round-trip
9
+ # source) and derive typed entries from it: `[cat: Pd]` is a
10
+ # catalyst entry, `[water]` stays free text. `;` separates
11
+ # entries inside one bracket; unknown role prefixes are free text
12
+ # (lossless — never an error).
8
13
  Conditions = Struct.new(:above, :below, keyword_init: true)
9
14
 
15
+ class Conditions
16
+ ROLE_PREFIXES = {
17
+ 'cat' => :catalyst, 'catalyst' => :catalyst,
18
+ 'solv' => :solvent, 'solvent' => :solvent,
19
+ 'reag' => :reagent, 'reagent' => :reagent
20
+ }.freeze
21
+ ENTRY_SEPARATOR = ';'
22
+ Entry = Struct.new(:role, :value, :side, keyword_init: true)
23
+
24
+ def entries
25
+ [[:above, above], [:below, below]].flat_map do |side, text|
26
+ entries_for(side, text)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def entries_for(side, text)
33
+ return [] if text.nil? || text.strip.empty?
34
+
35
+ text.split(ENTRY_SEPARATOR).filter_map do |chunk|
36
+ chunk = chunk.strip
37
+ next if chunk.empty?
38
+
39
+ role, value = split_role(chunk)
40
+ Entry.new(role: role, value: value, side: side)
41
+ end
42
+ end
43
+
44
+ def split_role(chunk)
45
+ prefix, rest = chunk.split(':', 2)
46
+ role = ROLE_PREFIXES[prefix.strip.downcase] unless rest.nil?
47
+ role ? [role, rest.strip] : [nil, chunk]
48
+ end
49
+ end
50
+
10
51
  ARROWS = {
11
52
  forward: { ascii: '->', mathml_entity: '→', wire: 'forward' },
12
53
  reverse: { ascii: '<-', mathml_entity: '←', wire: 'reverse' },
@@ -72,9 +113,7 @@ module AsciiChem
72
113
 
73
114
  def side_stoichiometry(side)
74
115
  side.each_with_object(Hash.new(0)) do |molecule, out|
75
- coefficient = (molecule.coefficient || '1').to_i
76
- coefficient = 1 if coefficient.zero?
77
- out[molecule.to_text.sub(/\A\d+/, '')] += coefficient
116
+ out[molecule.to_text.sub(/\A\d+/, '')] += molecule.stoichiometric_coefficient
78
117
  end
79
118
  end
80
119
  end
@@ -73,7 +73,9 @@ module AsciiChem
73
73
  Model::Atom.new(
74
74
  element: element,
75
75
  x2: x, y2: y, z2: z,
76
- isotope: isotope_from_mass_diff(element, mass_diff)
76
+ isotope: isotope_from_mass_diff(element, mass_diff),
77
+ chirality: chirality_from_parity(line[39, 3].to_i),
78
+ hydrogens: hydrogens_from_hcount(line[42, 3].to_i)
77
79
  )
78
80
  end
79
81
  end
@@ -153,6 +155,20 @@ module AsciiChem
153
155
  def isotope_from_mass_diff(_element, mass_diff)
154
156
  nil
155
157
  end
158
+
159
+ # MDL atom parity: 1 odd, 2 even, 0/3 not stereo (or either).
160
+ # The parity is relative to the neighbour order in the bond
161
+ # block, which the linearizer preserves — the same ordering the
162
+ # SMILES TH1/TH2 convention uses.
163
+ def chirality_from_parity(parity)
164
+ { 1 => 'TH1', 2 => 'TH2' }[parity]
165
+ end
166
+
167
+ # MDL H-count code: 0 default, 1 H0, 2 H1, 3 H2, ...
168
+ def hydrogens_from_hcount(code)
169
+ code >= 1 ? code - 1 : nil
170
+ end
171
+
156
172
  def atoms_count
157
173
  field(3, 0, 3).to_i
158
174
  end
@@ -7,12 +7,21 @@ module AsciiChem
7
7
  # computed (Layout walks atoms in the same order as
8
8
  # Structure::Graph, so positions map by index). Charges and
9
9
  # isotopes are emitted as `M CHG` / `M ISO` property lines.
10
+ #
11
+ # Tetrahedral chirality crosses as the atom parity field
12
+ # (TH1 → 1 odd, TH2 → 2 even) relative to the neighbour order in
13
+ # the bond block, which is the model's linear order — the InChI
14
+ # binary consumes it when wedges or coordinates are absent.
15
+ # Directional bonds (`/`, `\`, E/Z) have no V2000 field: the
16
+ # model's Bond#direction is documented as a molfile loss rather
17
+ # than silently invented from geometry.
10
18
  class Writer
11
19
  BOND_TYPES = {
12
20
  single: 1, double: 2, triple: 3, aromatic: 4,
13
21
  wedge: 1, hash: 1
14
22
  }.freeze
15
23
  BOND_STEREO = { wedge: 1, hash: 6 }.freeze
24
+ CHIRALITY_PARITY = { 'TH1' => 1, 'TH2' => 2 }.freeze
16
25
 
17
26
  def initialize(molecule, name: nil)
18
27
  @molecule = molecule
@@ -21,7 +30,7 @@ module AsciiChem
21
30
 
22
31
  def write
23
32
  atoms, edges = Structure::Graph.build(@molecule)
24
- raise ParseError, "molecule has no bonds — a formula is not a structure" if edges.empty?
33
+ raise ParseError, "molecule has no bonds — a formula is not a structure" if edges.empty? && atoms.length > 1
25
34
 
26
35
  lines = []
27
36
  lines << @name.to_s
@@ -32,8 +41,10 @@ module AsciiChem
32
41
  layout = nil
33
42
  atoms.each_with_index do |atom, index|
34
43
  x, y = coordinates(atom, index, atoms)
35
- lines << format("%10.4f%10.4f%10.4f %-3s 0 0 0 0 0 0 0 0 0 0 0 0",
36
- x, y, atom.z2 || 0.0, atom.element)
44
+ lines << format("%10.4f%10.4f%10.4f %-3s 0 0%3d%3d 0 0 0 0 0 0 0 0 0",
45
+ x, y, atom.z2 || 0.0, atom.element,
46
+ CHIRALITY_PARITY.fetch(atom.chirality, 0),
47
+ hcount_code(atom))
37
48
  end
38
49
 
39
50
  edges.each do |edge|
@@ -53,6 +64,12 @@ module AsciiChem
53
64
 
54
65
  private
55
66
 
67
+ # MDL H-count code: 0 default, 1 H0, 2 H1, 3 H2, ... — so a
68
+ # stated count n crosses as n + 1.
69
+ def hcount_code(atom)
70
+ atom.hydrogens.nil? ? 0 : atom.hydrogens + 1
71
+ end
72
+
56
73
  # Authored coordinates win; otherwise positions from the
57
74
  # deterministic 2D layout (same walk order as the graph).
58
75
  def coordinates(atom, index, atoms)
@@ -36,6 +36,8 @@ module AsciiChem
36
36
  # Sum of explicit bond orders at an atom (wedge/hash/dative/wavy
37
37
  # bonds order as single; aromatic orders as 1 — Hückel alternation
38
38
  # is a ring-perception concern, not a bond-order sum concern).
39
+ # This is the valence-feasibility sum: the linter's max-valence
40
+ # check consumes it.
39
41
  def bond_order_sum(atom_index, edges)
40
42
  edges.sum do |edge|
41
43
  next 0 unless edge.from == atom_index || edge.to == atom_index
@@ -44,45 +46,78 @@ module AsciiChem
44
46
  end
45
47
  end
46
48
 
49
+ # Hydrogen-counting sum: aromatic bonds order as 1.5 (Hückel), so
50
+ # both spellings of benzene sum to 3.0 at every carbon and perceive
51
+ # one implicit hydrogen — the two syntaxes share semantics. Whole
52
+ # numbers only: a fractional remainder means the Hückel arithmetic
53
+ # cannot decide (fused aromatic-schematic atoms), and perception
54
+ # declines rather than invents.
55
+ def hydrogen_valence_sum(atom_index, edges)
56
+ edges.sum do |edge|
57
+ next 0 unless edge.from == atom_index || edge.to == atom_index
58
+
59
+ edge.kind == :aromatic ? 1.5 : BOND_ORDER.fetch(edge.kind, 1)
60
+ end
61
+ end
62
+
47
63
  # Implicit hydrogens for an atom: the smallest default valence
48
- # that the explicit bond order fits, minus the bond order.
64
+ # that the hydrogen-valence sum fits, minus that sum. The sum must
65
+ # come from #hydrogen_valence_sum (aromatic-aware).
49
66
  # - Atoms that STATE hydrogens ([NH4+], [CH4]) return 0 — never
50
67
  # add to what the source said.
51
68
  # - Charged atoms return nil: charge algebra on valence is
52
69
  # element-specific and deferred until the perception pass grows
53
70
  # one (wrong implicit hydrogens are worse than none).
54
- # - Elements without a table entry, or explicit orders exceeding
55
- # every default, return nil — never invent.
56
- def implicit_hydrogens(atom, bond_order_sum)
71
+ # - Elements without a table entry, non-integral or over-valent
72
+ # sums return nil — never invent.
73
+ def implicit_hydrogens(atom, valence_sum)
57
74
  return 0 if atom.hydrogens
58
75
  return nil if atom.charge
59
76
 
60
- valences = DEFAULT_VALENCES[atom.element]
61
- return nil if valences.nil?
77
+ deficits = valence_deficits(atom.element, valence_sum)
78
+ return nil if deficits.empty?
79
+
80
+ deficits.min.to_i
81
+ end
62
82
 
63
- fitting = valences.select { |v| v >= bond_order_sum }
64
- return nil if fitting.empty?
83
+ # Whole-number, non-negative gaps between each default valence and
84
+ # the hydrogen-valence sum; empty when the element is unknown or
85
+ # no valence fits integrally.
86
+ def valence_deficits(element, valence_sum)
87
+ valences = DEFAULT_VALENCES[element]
88
+ return [].freeze if valences.nil?
65
89
 
66
- fitting.min - bond_order_sum
90
+ valences.map { |v| v - valence_sum }
91
+ .select { |deficit| deficit >= 0 && deficit.modulo(1).zero? }
67
92
  end
68
93
 
69
- # Six-membered cycles where every atom is sp2 (participates in a
70
- # double bond) or aromatic-flagged — the practical fused-system
71
- # rule: per-ring strict alternation can never see both rings of
72
- # naphthalene (the shared bond is single), but per-atom sp2 does.
73
- # Returns arrays of atom indexes, one per perceived ring, or an
74
- # 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.
75
105
  def aromatic_rings(atoms, edges)
76
106
  cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
77
- .select { |cycle| cycle.length == 6 }
78
- 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) } }
79
110
  end
80
111
 
81
- def sp2?(atom_index, atoms, edges)
112
+ def sp2?(atom_index, atoms, edges, cyclic_atoms)
82
113
  return true if atoms[atom_index].aromatic == true
83
114
 
84
115
  edges.any? do |edge|
85
- (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)
86
121
  end
87
122
  end
88
123
 
@@ -1,13 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- # Reaction-roles perception (TODO.v2/13 item 4, slice A): the
5
- # chemistry-writing convention puts catalysts and light reagents
6
- # ABOVE the arrow and solvents/conditions BELOW; the conditions
7
- # text carries them free-form. This derives structured role hints
8
- # from that convention — pure functions, nothing mutates. A full
9
- # roles syntax (structured entries in the grammar) is the later
10
- # slice.
4
+ # Reaction-role derivation (TODO.v2/13 item 4): explicit typed
5
+ # entries (`[cat: Pd]`, `[solv: THF]`, `[reag: NaH]`) win; free-text
6
+ # entries fall back to the writing convention — catalysts and light
7
+ # reagents ABOVE the arrow, solvents BELOW — classified by hint
8
+ # matching. Pure functions, nothing mutates.
11
9
  module ReactionRoles
12
10
  CATALYST_HINTS = %w[pd pt ni fe catalyst cat].freeze
13
11
  SOLVENT_HINTS = %w[h2o thf dmso dmf etoh meoh toluene solvent sol water].freeze
@@ -15,19 +13,37 @@ module AsciiChem
15
13
  module_function
16
14
 
17
15
  # Returns { catalysts: [strings], solvents: [strings],
18
- # reagents: [strings], other_above: [strings], other_below: [] }
19
- # — entries split from the conditions text by whitespace, each
20
- # classified by hint matching against the role vocabularies.
16
+ # reagents: [strings], other_below: [strings] }. Explicit roles
17
+ # route to their bucket regardless of side; free-text entries are
18
+ # split and classified by the above/below convention.
21
19
  def roles_for(reaction)
22
- conditions = reaction.conditions
23
- above = split_entries(conditions&.above)
24
- below = split_entries(conditions&.below)
25
- {
26
- catalysts: above.select { |e| matches?(e, CATALYST_HINTS) },
27
- reagents: above.reject { |e| matches?(e, CATALYST_HINTS) },
28
- solvents: below.select { |e| matches?(e, SOLVENT_HINTS) },
29
- other_below: below.reject { |e| matches?(e, SOLVENT_HINTS) }
30
- }
20
+ roles = { catalysts: [], reagents: [], solvents: [], other_below: [] }
21
+ entries = reaction.conditions ? reaction.conditions.entries : []
22
+ entries.each { |entry| assign(entry, roles) }
23
+ roles
24
+ end
25
+
26
+ def assign(entry, roles)
27
+ case entry.role
28
+ when :catalyst then roles[:catalysts] << entry.value
29
+ when :solvent then roles[:solvents] << entry.value
30
+ when :reagent then roles[:reagents] << entry.value
31
+ else classify_free(entry, roles)
32
+ end
33
+ end
34
+
35
+ def classify_free(entry, roles)
36
+ split_entries(entry.value).each do |value|
37
+ roles[bucket(value, entry.side)] << value
38
+ end
39
+ end
40
+
41
+ def bucket(value, side)
42
+ if matches?(value, CATALYST_HINTS) then :catalysts
43
+ elsif matches?(value, SOLVENT_HINTS) then :solvents
44
+ elsif side == :above then :reagents
45
+ else :other_below
46
+ end
31
47
  end
32
48
 
33
49
  def split_entries(text)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module AsciiChem
6
+ module Rinchi
7
+ # Engine that shells out to the standalone IUPAC `rinchi-1`
8
+ # binary. The binary is a documented external dependency — nothing
9
+ # is vendored (vendoring requires the licence review recorded in
10
+ # TODO.v2/10, a maintainer decision).
11
+ #
12
+ # AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new
13
+ # AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new(bin: "/usr/local/bin/rinchi-1")
14
+ class BinaryEngine
15
+ DEFAULT_FLAGS = %w[-STDIO -AuxNone -NoLabels -Key].freeze
16
+
17
+ attr_reader :bin, :flags
18
+
19
+ def initialize(bin: 'rinchi-1', flags: DEFAULT_FLAGS)
20
+ super()
21
+ @bin = bin
22
+ @flags = flags.freeze
23
+ end
24
+
25
+ # reaction → RXN → rinchi-1 → Identity. The RXN emission is the
26
+ # pipe: reactants and products cross into the engine as V2000
27
+ # mol blocks inside one $RXN container.
28
+ def identity(reaction)
29
+ rxn = AsciiChem::Rxn.write(reaction)
30
+ out = execute(rxn)
31
+ rinchi = out[/^RInChI=\S+/]
32
+ raise AsciiChem::Error, "#{bin} produced no RInChI:\n#{out}" unless rinchi
33
+
34
+ Identity.new(
35
+ rinchi: rinchi,
36
+ short_rinchikey: out[/^Short-RInChIKey=(\S+)/, 1],
37
+ long_rinchikey: out[/^Long-RInChIKey=(\S+)/, 1]
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def execute(input)
44
+ out, err, status = Open3.capture3(bin, *flags, stdin_data: input)
45
+ unless status.success?
46
+ raise AsciiChem::Error, "#{bin} failed (exit #{status.exitstatus}): #{(err + out).strip[0, 500]}"
47
+ end
48
+
49
+ out
50
+ rescue Errno::ENOENT
51
+ raise EngineMissingError, "RInChI engine binary #{bin.inspect} not found — #{AsciiChem::Rinchi::INSTALL_GUIDE}"
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Rinchi
5
+ # Derived identity of a reaction: the standard RInChI string and
6
+ # its long/short InChIKeys. Value object returned by engines.
7
+ Identity = Struct.new(:rinchi, :short_rinchikey, :long_rinchikey,
8
+ keyword_init: true)
9
+ end
10
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Reaction identity via RInChI (TODO.v2/13 item 4; TODO.v2/10 pattern).
5
+ #
6
+ # Like InChI, the algorithm is never reimplemented: engines wrap the
7
+ # IUPAC RInChI software (`rinchi-1`). The pipe is
8
+ # Model::Reaction → RXN → engine → RInChI keys.
9
+ #
10
+ # Engines are opt-in: set `AsciiChem::Rinchi.engine` (or pass one
11
+ # per call). Without an engine every derivation raises
12
+ # EngineMissingError with install guidance — no silent fallback.
13
+ module Rinchi
14
+ autoload :BinaryEngine, 'asciichem/rinchi/binary_engine'
15
+ autoload :Identity, 'asciichem/rinchi/identity'
16
+
17
+ INSTALL_GUIDE = 'install the IUPAC RInChI software ' \
18
+ '(https://github.com/IUPAC-InChI/RInChI) and set ' \
19
+ 'AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new ' \
20
+ '(or pass bin: "path/to/rinchi-1")'
21
+
22
+ class << self
23
+ attr_accessor :engine
24
+
25
+ # Derives the identity of one reaction. Falls back to the
26
+ # configured engine when none is passed; raises
27
+ # EngineMissingError when neither exists.
28
+ def identity_for(reaction, engine: nil)
29
+ selected = engine || self.engine
30
+ raise EngineMissingError, "no RInChI engine configured — #{INSTALL_GUIDE}" if selected.nil?
31
+
32
+ selected.identity(reaction)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Rxn
5
+ # Model reaction → MDL RXN V2000. Layout: `$RXN`, name, program,
6
+ # comment, counts (`%3d%3d` = reactant count, product count), then
7
+ # `$MOL` + one V2000 molfile per species (reactants first, then
8
+ # products).
9
+ class Writer
10
+ def initialize(reaction, name: nil)
11
+ @reaction = reaction
12
+ @name = name
13
+ end
14
+
15
+ def write
16
+ reactants = @reaction.reactants
17
+ products = @reaction.products
18
+ lines = ['$RXN', @name.to_s, ' AsciiChem', '',
19
+ format('%<reactants>3d%<products>3d',
20
+ reactants: reactants.length, products: products.length)]
21
+ (reactants + products).each do |molecule|
22
+ lines << '$MOL'
23
+ lines << AsciiChem::Molfile.write(molecule).chomp
24
+ end
25
+ lines.join("\n") << "\n"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # MDL RXN (reaction file) emission (TODO.v2/13 item 4, RInChI): a
5
+ # reaction as one $RXN container holding each reactant and product
6
+ # as a V2000 mol block. RXN carries species, not stoichiometry —
7
+ # the coefficient is model data with no place in the file, so it is
8
+ # not invented there.
9
+ module Rxn
10
+ autoload :Writer, 'asciichem/rxn/writer'
11
+
12
+ class << self
13
+ def write(reaction, name: nil)
14
+ Writer.new(reaction, name: name).write
15
+ end
16
+ end
17
+ end
18
+ end
@@ -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 = {}
@@ -167,17 +197,21 @@ module AsciiChem
167
197
  emit(continuation, index, @adjacency[index][continuation])
168
198
  end
169
199
 
170
- # Lower sorts first: single/aromatic bonds continue the chain,
171
- # then larger subtrees, then the lexicographically smallest atom
172
- # token (order-independent, so the canonical form does not
173
- # depend on how the input happened to number the atoms).
200
+ # Lower sorts first. A closure-bearing child continues the chain
201
+ # (SMILES writes ring-closure digits at the end of a traversal —
202
+ # this also keeps the written atom order equal to the model's,
203
+ # so re-parsing yields an equal model), then single/aromatic
204
+ # bonds, then larger subtrees, then the lexicographically
205
+ # smallest atom token (order-independent, so the canonical form
206
+ # does not depend on how the input happened to number the atoms).
174
207
  def continuation_rank(index, child)
175
208
  kind = @adjacency[index][child]
176
209
  priority = CONTINUATION_BONDS.include?(kind) ? 0 : 1
210
+ closure_first = @closures[child].empty? ? 1 : 0
177
211
  # The order-independent token: this rank runs inside the
178
212
  # written-order pre-pass, where stereo tokens are not yet
179
213
  # computable (and stereo does not affect the order).
180
- [priority, -@tree_size[child], atom_token_for(child, stereo: false)]
214
+ [closure_first, priority, -@tree_size[child], atom_token_for(child, stereo: false)]
181
215
  end
182
216
 
183
217
  def ring_digit_for(a, b)
@@ -201,6 +235,11 @@ module AsciiChem
201
235
  token = BOND_TOKENS[kind]
202
236
  raise ParseError, "#{kind} bonds have no SMILES form (v1 subset)" unless token
203
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
+
204
243
  return token if kind != :single && kind != :aromatic
205
244
 
206
245
  if kind == :single
@@ -228,7 +267,7 @@ module AsciiChem
228
267
  return @plain_tokens[index] if !stereo && @plain_tokens[index]
229
268
 
230
269
  atom = @atoms[index]
231
- aromatic = atom.aromatic == true
270
+ aromatic = aromatic?(index)
232
271
  lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
233
272
  needs_bracket =
234
273
  atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
@@ -243,7 +282,8 @@ module AsciiChem
243
282
  t << chirality_token(index) if stereo && atom.chirality
244
283
  if atom.hydrogens
245
284
  t << 'H'
246
- t << atom.hydrogens.to_s if atom.hydrogens > 1
285
+ # Stated zero is H0, not a bare H (which means one).
286
+ t << atom.hydrogens.to_s unless atom.hydrogens == 1
247
287
  end
248
288
  t << charge_suffix(atom.charge) if atom.charge
249
289
  t << ']'
@@ -266,10 +306,6 @@ module AsciiChem
266
306
 
267
307
  "#{sign}#{count}"
268
308
  end
269
-
270
- def aromatic?(index)
271
- @atoms[index].aromatic == true
272
- end
273
309
  end
274
310
  end
275
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.2"
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",
data/lib/asciichem.rb CHANGED
@@ -29,11 +29,14 @@ module AsciiChem
29
29
  autoload :ModelAdapter, "asciichem/model_adapter"
30
30
  autoload :Molfile, "asciichem/molfile"
31
31
  autoload :Parser, "asciichem/parser"
32
+ autoload :MechanismGraph, 'asciichem/mechanism_graph'
32
33
  autoload :Perception, 'asciichem/perception'
33
34
  autoload :ReactionRoles, 'asciichem/reaction_roles'
34
35
  autoload :PeriodicTable, "asciichem/periodic_table"
35
36
  autoload :Resolver, "asciichem/resolver"
36
37
  autoload :RingBonds, "asciichem/ring_bonds"
38
+ autoload :Rinchi, "asciichem/rinchi"
39
+ autoload :Rxn, "asciichem/rxn"
37
40
  autoload :Smiles, "asciichem/smiles"
38
41
  autoload :Structure, "asciichem/structure"
39
42
  autoload :Transform, "asciichem/transform"
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.2
4
+ version: 0.31.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -238,6 +238,7 @@ files:
238
238
  - lib/asciichem/linter/unclosed_ring_check.rb
239
239
  - lib/asciichem/linter/valence_check.rb
240
240
  - lib/asciichem/linter/zmatrix_reference_check.rb
241
+ - lib/asciichem/mechanism_graph.rb
241
242
  - lib/asciichem/model.rb
242
243
  - lib/asciichem/model/atom.rb
243
244
  - lib/asciichem/model/bond.rb
@@ -275,7 +276,12 @@ files:
275
276
  - lib/asciichem/resolver/opsin.rb
276
277
  - lib/asciichem/resolver/pubchem.rb
277
278
  - lib/asciichem/resolver/substance.rb
279
+ - lib/asciichem/rinchi.rb
280
+ - lib/asciichem/rinchi/binary_engine.rb
281
+ - lib/asciichem/rinchi/identity.rb
278
282
  - lib/asciichem/ring_bonds.rb
283
+ - lib/asciichem/rxn.rb
284
+ - lib/asciichem/rxn/writer.rb
279
285
  - lib/asciichem/smiles.rb
280
286
  - lib/asciichem/smiles/parser.rb
281
287
  - lib/asciichem/smiles/writer.rb