asciichem 0.31.2 → 0.31.3

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: ab5305dc2ec00320b62efbfae105108d23c90b6f5704c64e0f2ef59ec8c85983
4
+ data.tar.gz: 7bee58c136c5590646a736c42b900c4c4322fc7beeefcc9980b4ca5bfae6c4b8
5
5
  SHA512:
6
- metadata.gz: 75aa3b257cb79d94a5fb7308b89b4a9ce1fb827dfbe073e96723fdfbe8c8f8991ef1e37d0a0af44b4c8e52c318811f63baf064cb2db646995420f6733e3114da
7
- data.tar.gz: a33d70d38648147044a6eedf5a3e0afe171331862a7f08ea561ee52e3438bb3d3750636b85a2cbfd1d4a06e16937bb57f60616ee2a10f415ab479764741ef066
6
+ metadata.gz: a59410198a58a61ff195ed6ca2e4a5a7d20d751af9dc8690e1d38405e68a81ff3470ac532c9cd4517ed470fe814dab34db1221357a09ab63919bab78b57795b4
7
+ data.tar.gz: e1da749ccc43b3b1e00db74640e761b249d2445159026729780c11fa8b54d18c6c9d51f0655317229a9cd35cc1ffa727477f0c5be9033438be25fb8388acfa35
data/CHANGELOG.md CHANGED
@@ -3,6 +3,44 @@
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.3] - 2026-10-20
7
+
8
+ ### Added
9
+ - Perception in the shared corpus (TODO.v2/13 item 2, slice C):
10
+ implicit hydrogens order aromatic bonds at 1.5 (Hückel) and declare
11
+ a value only when a default valence fits integrally — so
12
+ `c1ccccc1` and `C1=CC=CC=C1` perceive identically, and
13
+ aromatic-schematic fusion atoms decline rather than invent. Gated
14
+ by `fixtures/perception.json` in all three implementations.
15
+ - Structured condition entries (item 4, slice 1):
16
+ `Conditions#entries` derives typed `cat:`/`solv:`/`reag:` entries
17
+ (semicolon-separated) from the verbatim bracket text; free text and
18
+ unknown prefixes stay lossless, and the wire form is unchanged.
19
+ `ReactionRoles` prefers explicit roles over the above/below
20
+ heuristic.
21
+ - Reaction stoichiometry as model data (slice 2):
22
+ `Reaction#stoichiometry` and `Molecule#stoichiometric_coefficient`
23
+ — one coefficient rule shared by the reaction, `BalanceCheck`, and
24
+ composition counting.
25
+ - Mechanism step graph (slice 4a): `MechanismGraph.for` derives
26
+ parsed steps, catalysts (species on both sides), and inter-step
27
+ links from the verbatim `mechanism{}` entries.
28
+ - Reaction identity via RInChI (slice 4b): `Rxn::Writer` emits MDL
29
+ RXN V2000 and `Rinchi::BinaryEngine` wraps the opt-in `rinchi-1`
30
+ binary (install guidance, `EngineMissingError`, nothing vendored).
31
+ - Molfile stereo (L3 slice 6): `Atom#chirality` crosses as MDL atom
32
+ parity and stated hydrogens as the H-count field, so the identity
33
+ pipe derives stereoisomer-distinct InChIs; `IdentityCrossCheck`
34
+ covers stereo-layer mismatches.
35
+
36
+ ### Fixed
37
+ - SMILES writer ring-closure emission (#103): a closure-bearing
38
+ child continues the chain, so the digit lands at the end of the
39
+ traversal — `parse(write(parse(s))) == parse(s)` and writer
40
+ stability now hold for ring structures such as `C1=CCC(C)C(C)C1`.
41
+ - SMILES writer rendered stated zero hydrogens as a bare `H` (one);
42
+ it now emits `H0`.
43
+
6
44
  ## [0.31.2] - 2026-10-20
7
45
 
8
46
  ### Added
@@ -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,72 @@
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, 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
+ end
32
+
33
+ def links(steps)
34
+ steps.each_with_index.flat_map do |from, i|
35
+ next [] unless from.reaction
36
+
37
+ from_products = sides_of(from.reaction)[:products].keys
38
+ steps[(i + 1)..].filter_map { |to| link_between(from, to, from_products) }
39
+ end
40
+ end
41
+
42
+ def link_between(from, to, from_products)
43
+ return nil unless to.reaction
44
+
45
+ shared = from_products & sides_of(to.reaction)[:reactants].keys
46
+ Link.new(from: from.label, to: to.label, species: shared) unless shared.empty?
47
+ end
48
+
49
+ def parse_reaction(text)
50
+ node = AsciiChem.parse(text).nodes.first
51
+ node if node.is_a?(AsciiChem::Model::Reaction) ||
52
+ node.is_a?(AsciiChem::Model::ReactionCascade)
53
+ rescue AsciiChem::ParseError
54
+ nil
55
+ end
56
+
57
+ # Coefficient-free species names per side; a cascade's entry side
58
+ # is its head and its exit side is its last step.
59
+ def sides_of(reaction)
60
+ case reaction
61
+ when AsciiChem::Model::Reaction
62
+ reaction.stoichiometry
63
+ when AsciiChem::Model::ReactionCascade
64
+ steps = reaction.steps
65
+ return nil if steps.empty?
66
+
67
+ { reactants: steps.first.stoichiometry[:reactants],
68
+ products: steps.last.stoichiometry[:products] }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -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,26 +46,49 @@ 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
94
  # Six-membered cycles where every atom is sp2 (participates in a
@@ -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
@@ -167,17 +167,21 @@ module AsciiChem
167
167
  emit(continuation, index, @adjacency[index][continuation])
168
168
  end
169
169
 
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).
170
+ # Lower sorts first. A closure-bearing child continues the chain
171
+ # (SMILES writes ring-closure digits at the end of a traversal —
172
+ # this also keeps the written atom order equal to the model's,
173
+ # so re-parsing yields an equal model), then single/aromatic
174
+ # bonds, then larger subtrees, then the lexicographically
175
+ # smallest atom token (order-independent, so the canonical form
176
+ # does not depend on how the input happened to number the atoms).
174
177
  def continuation_rank(index, child)
175
178
  kind = @adjacency[index][child]
176
179
  priority = CONTINUATION_BONDS.include?(kind) ? 0 : 1
180
+ closure_first = @closures[child].empty? ? 1 : 0
177
181
  # The order-independent token: this rank runs inside the
178
182
  # written-order pre-pass, where stereo tokens are not yet
179
183
  # computable (and stereo does not affect the order).
180
- [priority, -@tree_size[child], atom_token_for(child, stereo: false)]
184
+ [closure_first, priority, -@tree_size[child], atom_token_for(child, stereo: false)]
181
185
  end
182
186
 
183
187
  def ring_digit_for(a, b)
@@ -243,7 +247,8 @@ module AsciiChem
243
247
  t << chirality_token(index) if stereo && atom.chirality
244
248
  if atom.hydrogens
245
249
  t << 'H'
246
- t << atom.hydrogens.to_s if atom.hydrogens > 1
250
+ # Stated zero is H0, not a bare H (which means one).
251
+ t << atom.hydrogens.to_s unless atom.hydrogens == 1
247
252
  end
248
253
  t << charge_suffix(atom.charge) if atom.charge
249
254
  t << ']'
@@ -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.3"
5
5
  end
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.3
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