asciichem 0.31.3 → 0.31.5

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: 6c9a131b0a052014c60f5be8b74972de24db882f7db088b82bdea1c506709a46
4
+ data.tar.gz: 9b280851bdc1fb8c4f4681f1887796f2eb688298f8c495cd81a766a879fd8a09
5
5
  SHA512:
6
- metadata.gz: a59410198a58a61ff195ed6ca2e4a5a7d20d751af9dc8690e1d38405e68a81ff3470ac532c9cd4517ed470fe814dab34db1221357a09ab63919bab78b57795b4
7
- data.tar.gz: e1da749ccc43b3b1e00db74640e761b249d2445159026729780c11fa8b54d18c6c9d51f0655317229a9cd35cc1ffa727477f0c5be9033438be25fb8388acfa35
6
+ metadata.gz: d5580a3f5f314447b3f1f932b37396bb5741bd5363d96e4ae97cc95fcbcf7cfbcd0690ae16253c532d21b2c790093fda0652cd3d1a330f3bcbce9e1801c66e66
7
+ data.tar.gz: 44a0d45dfca800429e648a5a3c3adab685c5813c7c23d5be68afb55c7dc4cf34410b8cb16ff43681a439d9ec258c7e4f71aa141e7d3561a6a05c0ccc2cbc3f5b
data/ARCHITECTURE.adoc CHANGED
@@ -390,3 +390,81 @@ Ten registered checks (auto-load via `Linter::Base.register`):
390
390
  * `#atom_count` — total atoms (recurses through groups/nested molecules)
391
391
  * `#hill_formula` — Hill-system canonical formula (C first, H second, alpha)
392
392
  * `#formula_weight` — sum of atomic_mass * count (v0.13+)
393
+
394
+ == Perception pass (v0.31+)
395
+
396
+ `AsciiChem::Perception` derives the chemistry the syntax leaves
397
+ implicit, as pure functions over the neutral graph pair
398
+ (`Structure::Graph.build` returns `[atoms, edges]`):
399
+
400
+ * `bond_order_sum` — valence-feasibility sum (aromatic orders as 1);
401
+ consumed by the linter's `ValenceCheck`.
402
+ * `hydrogen_valence_sum` — hydrogen-counting sum (aromatic orders as
403
+ 1.5, Hückel); paired with `implicit_hydrogens`, which returns a
404
+ whole, non-negative count or declines (`nil`): stated hydrogens win,
405
+ charged atoms and non-integral/over-valent sums decline.
406
+ * `aromatic_rings` — π-electron counting for 5- and 6-membered cycles
407
+ (in-system double = 1, lone-pair donor N/O/S/P/Se/As = 2,
408
+ exocyclic-double carbon = 0; aromatic at totals 6 or 10). Charged
409
+ rings decline; aromatic-flagged rings are taken as stated. This
410
+ perceives furan/pyrrole/thiophene, fused systems, keeps quinones
411
+ Kekulé, and rejects four-π and three-membered rings.
412
+
413
+ Semantics are shared through the corpus (`fixtures/perception.json`) —
414
+ per-atom implicit hydrogens plus perceived ring indexes, declines
415
+ included — and mirrored in TypeScript and Python.
416
+
417
+ == Canonical SMILES (v0.31+)
418
+
419
+ `Smiles::Writer` is a deterministic serialiser that consumes the
420
+ perception pass: perceived aromatic rings are written lowercase with
421
+ their ring bonds implicit, so Kekulé input canonicalizes
422
+ (`C1=CC=CC=C1` → `c1ccccc1`, pyrrole → `c1c[nH]cc1`). Ring-closure
423
+ digits stay out of branches (a closure-bearing child continues the
424
+ chain). Stated-zero hydrogens emit `H0`. The canonical form is a fixed
425
+ point; corpus fixtures whose input is not canonical state the expected
426
+ output in `canonicalSmiles`.
427
+
428
+ == Reactions as semantics (v0.31+)
429
+
430
+ * `Reaction::Conditions#entries` — typed role entries
431
+ (`[cat: Pd; solv: THF]`) derived from the verbatim bracket text;
432
+ `ReactionRoles.roles_for` prefers explicit roles, falls back to the
433
+ above/below convention for free text.
434
+ * `Reaction#stoichiometry` and `Molecule#stoichiometric_coefficient` —
435
+ coefficient-free species → integer coefficient, one rule shared by
436
+ the reaction, `BalanceCheck`, and `element_counts`.
437
+ * `MechanismGraph.for(mechanism)` — parsed steps, catalysts (species on
438
+ both sides), inter-step links (product → later reactant), and the
439
+ step's electron-flow markers.
440
+ * `Mechanism::Flow` — `| flow: SOURCE > TARGET` clauses on a step
441
+ (semicolon-separated), verbatim names, carried on the wire as
442
+ `steps[].flows`; unknown clauses and malformed arrows raise.
443
+ * `Rxn::Writer` — MDL RXN V2000 emission (species, not
444
+ stoichiometry); `Rinchi::BinaryEngine` wraps the opt-in `rinchi-1`
445
+ binary for reaction identity (same contract as the InChI engine:
446
+ install guidance, `EngineMissingError`, nothing vendored).
447
+
448
+ == Depiction backends (v0.31+)
449
+
450
+ `AsciiChem::Layout.backend` is the depiction seam: a backend responds
451
+ to `layout(molecule, algorithm:)` and returns a `Layout::Result`.
452
+ `Layout::BuiltinBackend` (authored coordinates or deterministic elkrb
453
+ layered layout) is the default; external engines (RDKit, CDK, a Rust
454
+ layout core) plug in with one assignment. The renderer, the model, and
455
+ the wire form never learn which engine placed the atoms.
456
+
457
+ == Conformance contracts (v0.31+)
458
+
459
+ Two sibling repositories are normative, not the implementation:
460
+
461
+ * `asciichem-model` — versioned wire schemas (additive changes only),
462
+ LUTAML definitions, generated TS types; the gem vendors the schemas
463
+ into `spec/schemas` via `scripts/update-model-schemas.sh`.
464
+ * `asciichem-tests` — the shared corpus. Fixture families: parser
465
+ acceptance/rejection, canonical-text round-trip, CML, MathML
466
+ goldens, identifiers, linter expectations, SMILES/molfile
467
+ interchange, perception (`implicitHydrogens`, `aromaticRings`),
468
+ condition entries, mechanism flows, and 300 seeded generated models
469
+ shared across implementations. Contract changes are versioned and
470
+ state new expectations; fixtures are never weakened.
data/CHANGELOG.md CHANGED
@@ -3,6 +3,64 @@
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.5] - 2026-10-20
7
+
8
+ ### Changed
9
+ - Aromaticity perception is now **Hückel counting** (TODO.v2/13 item 2,
10
+ finishing the documented later slice): 5- and 6-membered cycles
11
+ count π electrons — in-system double = 1, lone-pair donor
12
+ (N/O/S/P/Se/As) with no double = 2, exocyclic-double carbon = 0 —
13
+ and are aromatic at totals 6 or 10. Charged rings decline;
14
+ aromatic-flagged rings are taken as stated. Furan, pyrrole, and
15
+ thiophene are perceived from Kekulé bond orders; quinones remain
16
+ excluded, as do four-π and three-membered rings.
17
+ - Canonical SMILES covers the five-membered heteroaromatics:
18
+ `C1=COC=C1` → `c1cocc1`, `C1=CNC=C1` → `c1c[nH]cc1`,
19
+ `C1=CSC=C1` → `c1cscc1`. A perceived pyrrole nitrogen is written
20
+ `[nH]` (bare aromatic `n` implies no hydrogen); inferred heteroatom
21
+ hydrogens apply only to rings perceived from Kekulé orders, so
22
+ source-aromatic thiophene never gains a phantom H.
23
+ - Corpus contract 0.8.0 (`asciichem-tests`): Hückel fixtures for
24
+ furan/pyrrole/thiophene, cyclopentadiene and cyclopropene pinned as
25
+ not aromatic, plus `canonicalSmiles` expectations. TS and Python
26
+ parity merged; the Python writer also emits `H0` for stated-zero
27
+ hydrogens.
28
+
29
+ ## [0.31.4] - 2026-10-20
30
+
31
+ ### Changed
32
+ - Canonical SMILES now consumes the perception pass (item 2): atoms
33
+ in perceived aromatic rings are written lowercase with implicit ring
34
+ bonds, so Kekulé input canonicalizes — `C1=CC=CC=C1` → `c1ccccc1`,
35
+ aspirin → `CC(=O)Oc1ccccc1C(=O)O`. Canonical-form stability is a
36
+ fixed point (`write(parse(write(x))) == write(x)`); composition and
37
+ connectivity are preserved. Perception's sp2 rule now requires the
38
+ double-bond partner to itself be in a ring, keeping quinone rings
39
+ Kekulé — the guard that makes aromatic canonicalization chemically
40
+ safe. Corpus contract 0.7.0 (`canonicalSmiles` fixture field).
41
+ - `Molecule#stoichiometric_coefficient` is the single coefficient
42
+ rule shared by `Reaction#stoichiometry`, `BalanceCheck`, and
43
+ `element_counts`.
44
+
45
+ ### Added
46
+ - Mechanism electron-flow markers (item 4, slice 4c): a step value may
47
+ carry `| flow: SOURCE > TARGET` clauses (semicolon-separated),
48
+ typed as `Mechanism::Flow`, carried by `MechanismGraph`, emitted on
49
+ the wire (`MechanismStep.flows`, asciichem-model v1 additive), and
50
+ re-emitted in canonical text. Malformed clauses raise; nothing is
51
+ dropped.
52
+ - Depiction backends (item 7): `AsciiChem::Layout.backend` lets a
53
+ depiction engine (RDKit, CDK, a Rust layout core) replace the
54
+ built-in elkrb pipeline without touching the renderer or the model.
55
+
56
+ ### Fixed
57
+ - Mechanism wire emission: the adapter passed raw step/spectator
58
+ strings to the reaction serializer, so `Mechanism#to_model_json`
59
+ had never worked. Steps and spectators are now parsed with the same
60
+ grammar; spectators that are not valid AsciiChem raise clearly.
61
+ - Test isolation: the CLI `--engine parsanol` spec leaked the engine
62
+ selection into later examples.
63
+
6
64
  ## [0.31.3] - 2026-10-20
7
65
 
8
66
  ### 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,62 @@ 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
+ # π-electron-donating heteroatoms: a ring atom with no double bond
95
+ # of its own contributes its lone pair (pyrrole N, furan O,
96
+ # thiophene S) — the 2 electrons that make those five-membered
97
+ # rings 4n+2.
98
+ LONE_PAIR_DONORS = %w[N O S P Se As].freeze
99
+
100
+ # Aromatic rings by Hückel counting: 5- and 6-membered cycles whose
101
+ # π-electron contributions total 6 or 10 (4n+2 with n >= 1; the
102
+ # n = 0 case is a charged three-membered ring, which perception
103
+ # declines, and a 2π six-ring would otherwise misread a diene as
104
+ # aromatic). Per
105
+ # atom: an in-system double bond contributes 1; a lone-pair donor
106
+ # with no double contributes 2; a carbon with only single bonds or
107
+ # an exocyclic double contributes 0. Charged atoms make the count
108
+ # undecidable and the ring is declined; aromatic-flagged rings are
109
+ # taken as stated.
110
+ #
111
+ # This sees fused systems (naphthalene's shared bond is single, but
112
+ # each atom's double bond lies in the ring system) and keeps
113
+ # quinones out (their carbons' only double bonds point at
114
+ # exocyclic oxygens, so the ring counts 5, not 4n+2).
100
115
  def aromatic_rings(atoms, edges)
101
116
  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) } }
117
+ cyclic_atoms = cycles.flatten.uniq
118
+ cycles.select { |cycle| aromatic_cycle?(cycle, atoms, edges, cyclic_atoms) }
104
119
  end
105
120
 
106
- def sp2?(atom_index, atoms, edges)
107
- return true if atoms[atom_index].aromatic == true
121
+ def aromatic_cycle?(cycle, atoms, edges, cyclic_atoms)
122
+ return true if cycle.any? { |i| atoms[i].aromatic == true }
123
+ return false unless [5, 6].include?(cycle.length)
108
124
 
125
+ contributions = cycle.map do |i|
126
+ ring_pi_electrons(atoms[i], i, edges, cyclic_atoms)
127
+ end
128
+ return false if contributions.any?(&:nil?)
129
+
130
+ [6, 10].include?(contributions.sum)
131
+ end
132
+
133
+ # π electrons one ring atom contributes, or nil when the count is
134
+ # undecidable (charged atom).
135
+ def ring_pi_electrons(atom, atom_index, edges, cyclic_atoms)
136
+ return nil if atom.charge
137
+ return 1 if in_system_double?(atom_index, edges, cyclic_atoms)
138
+ return 2 if LONE_PAIR_DONORS.include?(atom.element)
139
+
140
+ 0
141
+ end
142
+
143
+ def in_system_double?(atom_index, edges, cyclic_atoms)
109
144
  edges.any? do |edge|
110
- (edge.from == atom_index || edge.to == atom_index) && edge.kind == :double
145
+ next false unless edge.from == atom_index || edge.to == atom_index
146
+ next false unless edge.kind == :double
147
+
148
+ partner = edge.from == atom_index ? edge.to : edge.from
149
+ cyclic_atoms.include?(partner)
111
150
  end
112
151
  end
113
152
 
@@ -25,6 +25,10 @@ module AsciiChem
25
25
  }.freeze
26
26
  CONTINUATION_BONDS = %i[single aromatic].freeze
27
27
 
28
+ # Aromatic lowercase heteroatoms with an implicit hydrogen need
29
+ # brackets: bare `n`/`o`/`s` imply none.
30
+ HETERO_IMPLICIT_H = %w[N O S P Se As].freeze
31
+
28
32
  def initialize(molecule)
29
33
  @molecule = molecule
30
34
  end
@@ -36,6 +40,7 @@ module AsciiChem
36
40
  raise ParseError, 'molecule has no bonds — a formula is not a structure' if edges.empty? && atoms.length > 1
37
41
 
38
42
  @atoms = atoms
43
+ perceive_aromaticity(atoms, edges)
39
44
  build_adjacency(edges)
40
45
  @visited = {}
41
46
  @digits = Array.new(atoms.length) { [] }
@@ -66,6 +71,62 @@ module AsciiChem
66
71
 
67
72
  private
68
73
 
74
+ # The canonicalizer consumes the perception pass (TODO.v2/13
75
+ # item 2): atoms in perceived aromatic rings are written as
76
+ # aromatic (lowercase) and the ring bonds between them carry no
77
+ # bond symbol — Kekulé benzene canonicalizes to `c1ccccc1`, so
78
+ # both spellings converge. Perception's Hückel counting keeps
79
+ # quinone-like rings out.
80
+ #
81
+ # Hydrogens are inferred for lowercase carbon but NOT for
82
+ # heteroatoms: pyrrole's N must be written `[nH]` or the SMILES
83
+ # states a pyridine-type nitrogen instead. The perceived
84
+ # implicit count for ring atoms is recorded for that decision.
85
+ def perceive_aromaticity(atoms, edges)
86
+ @perceived_aromatic = {}
87
+ @perceived_ring_edges = {}
88
+ @perceived_hydrogens = {}
89
+ AsciiChem::Perception.aromatic_rings(atoms, edges).each do |ring|
90
+ ring.each do |index|
91
+ @perceived_aromatic[index] = true
92
+ next if atoms[index].aromatic == true
93
+
94
+ @perceived_hydrogens[index] = AsciiChem::Perception.implicit_hydrogens(
95
+ atoms[index],
96
+ AsciiChem::Perception.hydrogen_valence_sum(index, edges)
97
+ )
98
+ end
99
+ ring.each_with_index do |atom, i|
100
+ other = ring[(i + 1) % ring.length]
101
+ @perceived_ring_edges[[atom, other].minmax] = true
102
+ end
103
+ end
104
+ end
105
+
106
+ # Hydrogens the written token must state: the source's own count
107
+ # wins; otherwise only a ring PERCEIVED from Kekulé bond orders
108
+ # gets perception's implicit count. A source-aromatic atom
109
+ # already states its hydrogens the way SMILES does (`[nH]`), and
110
+ # aromatic 1.5 bond orders do not map onto heteroatom valences
111
+ # (thiophene's S would otherwise acquire a phantom H).
112
+ def writing_hydrogens(index)
113
+ stated = @atoms[index].hydrogens
114
+ return stated unless stated.nil?
115
+
116
+ @perceived_hydrogens[index]
117
+ end
118
+
119
+ # Aromatic for WRITING: the source flagged the atom, or
120
+ # perception found it in an aromatic ring.
121
+ def aromatic?(index)
122
+ @atoms[index].aromatic == true || @perceived_aromatic.key?(index)
123
+ end
124
+
125
+ def perceived_ring_bond?(from, to)
126
+ @perceived_ring_edges.key?([from, to].minmax) &&
127
+ aromatic?(from) && aromatic?(to)
128
+ end
129
+
69
130
  def build_adjacency(edges)
70
131
  @adjacency = Array.new(@atoms.length) { {} }
71
132
  @directions = {}
@@ -205,6 +266,11 @@ module AsciiChem
205
266
  token = BOND_TOKENS[kind]
206
267
  raise ParseError, "#{kind} bonds have no SMILES form (v1 subset)" unless token
207
268
 
269
+ # A bond inside a perceived aromatic ring is implied by the
270
+ # lowercase atoms: writing `=`/`-` there would state Kekulé
271
+ # alternation the canonical form does not carry.
272
+ return '' if perceived_ring_bond?(from, to)
273
+
208
274
  return token if kind != :single && kind != :aromatic
209
275
 
210
276
  if kind == :single
@@ -232,10 +298,14 @@ module AsciiChem
232
298
  return @plain_tokens[index] if !stereo && @plain_tokens[index]
233
299
 
234
300
  atom = @atoms[index]
235
- aromatic = atom.aromatic == true
301
+ aromatic = aromatic?(index)
236
302
  lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
303
+ hyd = writing_hydrogens(index)
304
+ hetero_needs_h = aromatic && lowercase && hyd&.positive? &&
305
+ HETERO_IMPLICIT_H.include?(atom.element)
237
306
  needs_bracket =
238
- atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
307
+ atom.charge || atom.isotope || !atom.hydrogens.nil? || atom.chirality ||
308
+ hetero_needs_h ||
239
309
  (!ORGANIC.include?(atom.element) && !lowercase) ||
240
310
  (aromatic && !lowercase)
241
311
  token =
@@ -245,10 +315,10 @@ module AsciiChem
245
315
  t << atom.isotope if atom.isotope
246
316
  t << symbol
247
317
  t << chirality_token(index) if stereo && atom.chirality
248
- if atom.hydrogens
318
+ if !atom.hydrogens.nil? || hetero_needs_h
249
319
  t << 'H'
250
320
  # Stated zero is H0, not a bare H (which means one).
251
- t << atom.hydrogens.to_s unless atom.hydrogens == 1
321
+ t << hyd.to_s unless hyd == 1
252
322
  end
253
323
  t << charge_suffix(atom.charge) if atom.charge
254
324
  t << ']'
@@ -271,10 +341,6 @@ module AsciiChem
271
341
 
272
342
  "#{sign}#{count}"
273
343
  end
274
-
275
- def aromatic?(index)
276
- @atoms[index].aromatic == true
277
- end
278
344
  end
279
345
  end
280
346
  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.5"
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.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.