asciichem 0.31.0 → 0.31.2

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.
@@ -21,7 +21,7 @@ module AsciiChem
21
21
  LOWERCASE_AROMATIC = %w[C N O S P B Se As].freeze
22
22
  ORGANIC = %w[B C N O P S F Cl Br I].freeze
23
23
  BOND_TOKENS = {
24
- single: "-", double: "=", triple: "#", quadruple: "$", aromatic: ":"
24
+ single: '-', double: '=', triple: '#', quadruple: '$', aromatic: ':'
25
25
  }.freeze
26
26
  CONTINUATION_BONDS = %i[single aromatic].freeze
27
27
 
@@ -33,9 +33,7 @@ module AsciiChem
33
33
  atoms, edges = Structure::Graph.build(@molecule)
34
34
  # A single-atom component is valid SMILES (water "O", ions);
35
35
  # multiple atoms with no bonds is a formula, not a structure.
36
- if edges.empty? && atoms.length > 1
37
- raise ParseError, "molecule has no bonds — a formula is not a structure"
38
- end
36
+ raise ParseError, 'molecule has no bonds — a formula is not a structure' if edges.empty? && atoms.length > 1
39
37
 
40
38
  @atoms = atoms
41
39
  build_adjacency(edges)
@@ -53,6 +51,7 @@ module AsciiChem
53
51
 
54
52
  build_tree(0, nil)
55
53
  compute_tree_size(0)
54
+ compute_written_orders
56
55
  emit(0, nil, nil)
57
56
 
58
57
  @units.map do |unit|
@@ -69,10 +68,60 @@ module AsciiChem
69
68
 
70
69
  def build_adjacency(edges)
71
70
  @adjacency = Array.new(@atoms.length) { {} }
71
+ @directions = {}
72
+ @model_neighbours = Array.new(@atoms.length) { [] }
72
73
  edges.each do |edge|
74
+ key = [edge.from, edge.to]
73
75
  @adjacency[edge.from][edge.to] = edge.kind
74
76
  @adjacency[edge.to][edge.from] = edge.kind
77
+ key = key.minmax
78
+ @directions[key] = edge.direction if edge.direction
79
+ # The edge array is in model node order: each atom's
80
+ # reference neighbours, for SMILES parity bookkeeping.
81
+ @model_neighbours[edge.from] << edge.to
82
+ @model_neighbours[edge.to] << edge.from
83
+ end
84
+ end
85
+
86
+ # The order neighbours are WRITTEN in: parent first, then ring
87
+ # closures, then branches (rank order), then the continuation.
88
+ # Tetrahedral parity flips with odd permutations of this order
89
+ # against the model's reference order, so the emitted @/@@ is
90
+ # corrected per atom. Ring-closure neighbours in the reference
91
+ # order are slice-3 scope (acyclic stereo is corrected here).
92
+ def compute_written_orders
93
+ @written_order = Array.new(@atoms.length)
94
+ walk = lambda do |index, parent|
95
+ order = parent ? [parent] : []
96
+ order += @closures[index].dup
97
+ # Appearance order: branches (rank order), then the
98
+ # continuation — emit() writes them that way.
99
+ ordered = @tree_children[index].sort_by { |child| continuation_rank(index, child) }
100
+ order += ordered[1..] + [ordered.first] unless ordered.empty?
101
+ @written_order[index] = order
102
+ @tree_children[index].each { |child| walk.call(child, index) }
103
+ end
104
+ walk.call(0, nil)
105
+ end
106
+
107
+ # TH1 -> TH2 flip when the written order is an odd permutation
108
+ # of the model's reference order (implicit hydrogens sort first
109
+ # on both sides).
110
+ def chirality_token(index)
111
+ chirality = @atoms[index].chirality
112
+ return '' unless chirality
113
+
114
+ reference = ([:h] if @atoms[index].hydrogens) || []
115
+ reference += @model_neighbours[index]
116
+ written = ([:h] if @atoms[index].hydrogens) || []
117
+ written += @written_order[index]
118
+ inversions = 0
119
+ written.each_with_index do |item, i|
120
+ written[(i + 1)..].each do |later|
121
+ inversions += 1 if reference.index(item) > reference.index(later)
122
+ end
75
123
  end
124
+ chirality == 'TH2' || inversions.odd? ? '@@' : '@'
76
125
  end
77
126
 
78
127
  # Post-order subtree sizes within the DFS tree.
@@ -111,9 +160,9 @@ module AsciiChem
111
160
  continuation, *branches = ordered
112
161
 
113
162
  branches.each do |child|
114
- @units << [:literal, "("]
163
+ @units << [:literal, '(']
115
164
  emit(child, index, @adjacency[index][child])
116
- @units << [:literal, ")"]
165
+ @units << [:literal, ')']
117
166
  end
118
167
  emit(continuation, index, @adjacency[index][continuation])
119
168
  end
@@ -125,7 +174,10 @@ module AsciiChem
125
174
  def continuation_rank(index, child)
126
175
  kind = @adjacency[index][child]
127
176
  priority = CONTINUATION_BONDS.include?(kind) ? 0 : 1
128
- [priority, -@tree_size[child], atom_token(child)]
177
+ # The order-independent token: this rank runs inside the
178
+ # written-order pre-pass, where stereo tokens are not yet
179
+ # computable (and stereo does not affect the order).
180
+ [priority, -@tree_size[child], atom_token_for(child, stereo: false)]
129
181
  end
130
182
 
131
183
  def ring_digit_for(a, b)
@@ -133,7 +185,7 @@ module AsciiChem
133
185
  digit = @digit_by_edge[key]
134
186
  return digit if digit
135
187
 
136
- raise ParseError, "too many ring closures for SMILES output" if @next_digit > 9
188
+ raise ParseError, 'too many ring closures for SMILES output' if @next_digit > 9
137
189
 
138
190
  digit = @next_digit.to_s
139
191
  @next_digit += 1
@@ -145,48 +197,61 @@ module AsciiChem
145
197
  digit
146
198
  end
147
199
 
148
-
149
200
  def bond_token(kind, from, to)
150
201
  token = BOND_TOKENS[kind]
151
202
  raise ParseError, "#{kind} bonds have no SMILES form (v1 subset)" unless token
152
203
 
153
204
  return token if kind != :single && kind != :aromatic
154
205
 
155
- # Explicit only when the short form would change the meaning:
156
- # a single bond between two aromatic atoms must be written;
157
- # an aromatic bond is implicit between two aromatic atoms.
158
206
  if kind == :single
159
- aromatic?(from) && aromatic?(to) ? token : ""
207
+ direction = @directions[[from, to].minmax]
208
+ return direction == :up ? '/' : '\\' if direction
209
+
210
+ return '' unless aromatic?(from) && aromatic?(to)
211
+
212
+ # Explicit only when the short form would change the meaning:
213
+ # a single bond between two aromatic atoms must be written.
214
+ token
160
215
  else
161
- aromatic?(from) && aromatic?(to) ? "" : token
216
+ aromatic?(from) && aromatic?(to) ? '' : token
162
217
  end
163
218
  end
164
219
 
165
220
  def atom_token(index)
166
- @atom_tokens ||= {}
167
- @atom_tokens[index] ||= begin
168
- atom = @atoms[index]
169
- aromatic = atom.aromatic == true
170
- lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
171
- needs_bracket =
172
- atom.charge || atom.isotope || atom.hydrogens ||
173
- (!ORGANIC.include?(atom.element) && !lowercase) ||
174
- (aromatic && !lowercase)
175
- unless needs_bracket
176
- bare_symbol(atom, lowercase)
177
- else
221
+ atom_token_for(index, stereo: true)
222
+ end
223
+
224
+ # `stereo: false` gives the order-independent form for ranking
225
+ # comparisons; the emitted token carries the corrected @/@@.
226
+ def atom_token_for(index, stereo:)
227
+ @plain_tokens ||= {}
228
+ return @plain_tokens[index] if !stereo && @plain_tokens[index]
229
+
230
+ atom = @atoms[index]
231
+ aromatic = atom.aromatic == true
232
+ lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
233
+ needs_bracket =
234
+ atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
235
+ (!ORGANIC.include?(atom.element) && !lowercase) ||
236
+ (aromatic && !lowercase)
237
+ token =
238
+ if needs_bracket
178
239
  symbol = lowercase ? atom.element.downcase : atom.element
179
- token = +"["
180
- token << atom.isotope if atom.isotope
181
- token << symbol
240
+ t = +'['
241
+ t << atom.isotope if atom.isotope
242
+ t << symbol
243
+ t << chirality_token(index) if stereo && atom.chirality
182
244
  if atom.hydrogens
183
- token << "H"
184
- token << atom.hydrogens.to_s if atom.hydrogens > 1
245
+ t << 'H'
246
+ t << atom.hydrogens.to_s if atom.hydrogens > 1
185
247
  end
186
- token << charge_suffix(atom.charge) if atom.charge
187
- token << "]"
248
+ t << charge_suffix(atom.charge) if atom.charge
249
+ t << ']'
250
+ else
251
+ bare_symbol(atom, lowercase)
188
252
  end
189
- end
253
+ @plain_tokens[index] = token unless stereo
254
+ token
190
255
  end
191
256
 
192
257
  def bare_symbol(atom, lowercase)
@@ -6,7 +6,7 @@ module AsciiChem
6
6
  # atoms become edges (pending-bond semantics, same as the Layout
7
7
  # walker); ring-closure digits become edges via AsciiChem::RingBonds.
8
8
  module Graph
9
- Edge = Struct.new(:from, :to, :kind, keyword_init: true)
9
+ Edge = Struct.new(:from, :to, :kind, :direction, keyword_init: true)
10
10
 
11
11
  class << self
12
12
  # Returns [atoms, edges] where atoms is the atom array in
@@ -29,7 +29,8 @@ module AsciiChem
29
29
  index_by_object_id[node.object_id] = index
30
30
  atoms << node
31
31
  if pending && last
32
- edges << Edge.new(from: last, to: index, kind: pending.kind)
32
+ edges << Edge.new(from: last, to: index, kind: pending.kind,
33
+ direction: pending.direction)
33
34
  end
34
35
  last = index
35
36
  pending = nil
@@ -32,7 +32,7 @@ module AsciiChem
32
32
  @edges.each do |edge|
33
33
  first, last = [edge.from, edge.to].minmax
34
34
  if last - first == 1
35
- @token_before[last] = edge.kind
35
+ @token_before[last] = edge
36
36
  else
37
37
  digit = allocate_digit(first, last)
38
38
  @digits_for[first] += digit
@@ -42,8 +42,11 @@ module AsciiChem
42
42
 
43
43
  result = []
44
44
  @atoms.each_with_index do |atom, index|
45
- kind = @token_before[index]
46
- result << AsciiChem::Model::Bond.new(kind: kind || :single) if index.positive? && kind
45
+ edge = @token_before[index]
46
+ if index.positive? && edge
47
+ result << AsciiChem::Model::Bond.new(kind: edge.kind || :single,
48
+ direction: edge.direction)
49
+ end
47
50
  atom.ring_closures = @digits_for[index] unless @digits_for[index].empty?
48
51
  result << atom
49
52
  end
@@ -62,6 +62,8 @@ module AsciiChem
62
62
 
63
63
  # -- bonds ------------------------------------------------------------
64
64
 
65
+ rule(direction_up: simple(:_u)) { Model::Bond.new(kind: :single, direction: :up) }
66
+ rule(direction_down: simple(:_d)) { Model::Bond.new(kind: :single, direction: :down) }
65
67
  rule(quadruple: simple(:_q)) { Model::Bond.new(kind: :quadruple) }
66
68
  rule(triple: simple(:_t)) { Model::Bond.new(kind: :triple) }
67
69
  rule(wedge: simple(:_w)) { Model::Bond.new(kind: :wedge) }
@@ -597,6 +599,7 @@ module AsciiChem
597
599
  lone_pairs: lewis_count(hash[:lone_pairs]),
598
600
  radical_electrons: lewis_count(hash[:radical_electrons]),
599
601
  ring_closures: ring_closures_string(hash[:ring_closures]),
602
+ chirality: hash[:chirality]&.to_s,
600
603
  x2: float_or_nil(hash[:x2]),
601
604
  y2: float_or_nil(hash[:y2]),
602
605
  z2: float_or_nil(hash[:z2]),
@@ -637,7 +640,7 @@ module AsciiChem
637
640
 
638
641
  def initialize(element, isotope: nil, subscript: nil, superscript: nil,
639
642
  lone_pairs: nil, radical_electrons: nil,
640
- ring_closures: nil,
643
+ ring_closures: nil, chirality: nil,
641
644
  x2: nil, y2: nil, z2: nil, atom_parity: nil,
642
645
  spin_multiplicity: nil, atom_title: nil,
643
646
  x_fract: nil, y_fract: nil, z_fract: nil)
@@ -648,6 +651,7 @@ module AsciiChem
648
651
  @lone_pairs = lone_pairs
649
652
  @radical_electrons = radical_electrons
650
653
  @ring_closures = ring_closures
654
+ @chirality = chirality
651
655
  @x2 = x2
652
656
  @y2 = y2
653
657
  @z2 = z2
@@ -672,6 +676,7 @@ module AsciiChem
672
676
  lone_pairs: positive_int(@lone_pairs),
673
677
  radical_electrons: positive_int(@radical_electrons),
674
678
  ring_closures: @ring_closures,
679
+ chirality: @chirality,
675
680
  x2: @x2,
676
681
  y2: @y2,
677
682
  z2: @z2,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.31.0"
4
+ VERSION = "0.31.2"
5
5
  end
@@ -18,6 +18,7 @@ module AsciiChem
18
18
  attribute :ring_closures, :string
19
19
  attribute :aromatic, :boolean
20
20
  attribute :hydrogens, :integer
21
+ attribute :chirality, :string
21
22
  json do
22
23
  map "element", to: :element
23
24
  map "isotope", to: :isotope
@@ -28,6 +29,7 @@ module AsciiChem
28
29
  map "radicalElectrons", to: :radical_electrons
29
30
  map "ringClosures", to: :ring_closures
30
31
  map "aromatic", to: :aromatic
32
+ map "chirality", to: :chirality
31
33
  map "hydrogens", to: :hydrogens
32
34
  end
33
35
  end
@@ -90,8 +92,10 @@ module AsciiChem
90
92
  wire_type "bond"
91
93
 
92
94
  attribute :kind, :string
95
+ attribute :direction, :string
93
96
  json do
94
97
  map "kind", to: :kind
98
+ map "direction", to: :direction
95
99
  end
96
100
  end
97
101
  end
@@ -107,7 +107,8 @@ module AsciiChem
107
107
  radical_electrons: int_or_nil(atom.radical_electrons),
108
108
  ring_closures: str_or_nil(atom.ring_closures),
109
109
  aromatic: bool_or_nil(atom.aromatic),
110
- hydrogens: int_or_nil(atom.hydrogens)
110
+ hydrogens: int_or_nil(atom.hydrogens),
111
+ chirality: str_or_nil(atom.chirality)
111
112
  )
112
113
  end
113
114
 
@@ -165,7 +166,8 @@ module AsciiChem
165
166
  end
166
167
 
167
168
  def bond_to_wire(node)
168
- AsciiChem::Wire::Bond.new(type: "bond", kind: node.kind.to_s)
169
+ AsciiChem::Wire::Bond.new(type: "bond", kind: node.kind.to_s,
170
+ direction: str_or_nil(node.direction&.to_s))
169
171
  end
170
172
 
171
173
  def identifier_to_wire(node)
@@ -302,7 +304,8 @@ module AsciiChem
302
304
  charge: wire.charge, oxidation_state: wire.oxidation_state,
303
305
  lone_pairs: wire.lone_pairs, radical_electrons: wire.radical_electrons,
304
306
  ring_closures: wire.ring_closures,
305
- aromatic: wire.aromatic, hydrogens: wire.hydrogens
307
+ aromatic: wire.aromatic, hydrogens: wire.hydrogens,
308
+ chirality: wire.chirality
306
309
  )
307
310
  end
308
311
 
@@ -324,7 +327,8 @@ module AsciiChem
324
327
  end
325
328
 
326
329
  def bond_from_wire(wire)
327
- AsciiChem::Model::Bond.new(kind: wire.kind.to_sym)
330
+ AsciiChem::Model::Bond.new(kind: wire.kind.to_sym,
331
+ direction: wire.direction&.to_sym)
328
332
  end
329
333
 
330
334
  def identifier_from_wire(wire)
data/lib/asciichem.rb CHANGED
@@ -29,6 +29,8 @@ module AsciiChem
29
29
  autoload :ModelAdapter, "asciichem/model_adapter"
30
30
  autoload :Molfile, "asciichem/molfile"
31
31
  autoload :Parser, "asciichem/parser"
32
+ autoload :Perception, 'asciichem/perception'
33
+ autoload :ReactionRoles, 'asciichem/reaction_roles'
32
34
  autoload :PeriodicTable, "asciichem/periodic_table"
33
35
  autoload :Resolver, "asciichem/resolver"
34
36
  autoload :RingBonds, "asciichem/ring_bonds"
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Differential conformance (TODO.v2/13 item 5): exports the property
5
+ # harness's generated models as wire JSON + canonical text so the
6
+ # TypeScript and Python implementations run the SAME cases (the
7
+ # cross-implementation differential gate). Deterministic seed; the
8
+ # output lives in asciichem-tests and is versioned like any corpus
9
+ # artifact.
10
+ #
11
+ # bundle exec ruby scripts/generate-conformance-cases \
12
+ # ../asciichem-tests/corpus/generated/models.json [count]
13
+ require "asciichem"
14
+ require "asciichem/wire_adapter"
15
+ require "fileutils"
16
+ require "json"
17
+
18
+ seed = 20_261_020
19
+ count = Integer(ARGV[1] || 300)
20
+ out_path = ARGV[0] || "../asciichem-tests/corpus/generated/models.json"
21
+
22
+ rng = Random.new(seed)
23
+ ELEMENTS = %w[H C N O F S P Cl].freeze
24
+ BOND_KINDS = %i[single double single triple].freeze
25
+
26
+ pick = ->(list) { list[rng.rand(list.length)] }
27
+
28
+ cases = []
29
+ count.times do
30
+ size = 2 + rng.rand(6)
31
+ atoms = Array.new(size) do
32
+ AsciiChem::Model::Atom.new(
33
+ element: pick.call(ELEMENTS),
34
+ charge: pick.call([nil, nil, nil, '2+', '-']),
35
+ chirality: pick.call([nil, nil, nil, nil, 'TH1', 'TH2'])
36
+ )
37
+ end
38
+ bonds = (1...size).map do
39
+ AsciiChem::Model::Bond.new(
40
+ kind: pick.call(BOND_KINDS),
41
+ direction: (pick.call([nil, nil, nil, :up, :down]) if rng.rand(6).zero?)
42
+ )
43
+ end
44
+ molecule = AsciiChem::Model::Molecule.new(nodes: atoms.zip(bonds).flatten.compact)
45
+ text = molecule.to_text
46
+ reparsed = AsciiChem.parse(text)
47
+ raise "seed #{seed} generated an unstable case: #{text.inspect}" unless reparsed.nodes.first == molecule
48
+
49
+ # The wire root is the formula document (the canonical root).
50
+ wire = AsciiChem::WireAdapter.to_wire(AsciiChem.parse(text))
51
+ # Framework serialization (lutaml-model), never hand-rolled.
52
+ cases << { "wire" => JSON.parse(wire.to_json), "text" => text }
53
+ end
54
+
55
+ FileUtils.mkdir_p(File.dirname(out_path))
56
+ File.write(out_path, JSON.pretty_generate({ "seed" => seed, "cases" => cases }) + "\n")
57
+ puts "wrote #{cases.length} cases to #{out_path}"
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.0
4
+ version: 0.31.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: 0.3.0
18
+ version: '0.4'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: 0.3.0
25
+ version: '0.4'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: elkrb
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -265,11 +265,14 @@ files:
265
265
  - lib/asciichem/molfile/parser.rb
266
266
  - lib/asciichem/molfile/writer.rb
267
267
  - lib/asciichem/parser.rb
268
+ - lib/asciichem/perception.rb
268
269
  - lib/asciichem/periodic_table.rb
270
+ - lib/asciichem/reaction_roles.rb
269
271
  - lib/asciichem/resolver.rb
270
272
  - lib/asciichem/resolver/adapter.rb
271
273
  - lib/asciichem/resolver/cache.rb
272
274
  - lib/asciichem/resolver/common_chemistry.rb
275
+ - lib/asciichem/resolver/opsin.rb
273
276
  - lib/asciichem/resolver/pubchem.rb
274
277
  - lib/asciichem/resolver/substance.rb
275
278
  - lib/asciichem/ring_bonds.rb
@@ -290,6 +293,7 @@ files:
290
293
  - lib/asciichem/wire/identity.rb
291
294
  - lib/asciichem/wire_adapter.rb
292
295
  - lib/asciichem/xml_builder.rb
296
+ - scripts/generate-conformance-cases
293
297
  - scripts/update-model-schemas.sh
294
298
  homepage: https://www.asciichem.org
295
299
  licenses: