asciichem 0.31.4 → 0.31.6

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: c9c3f297f95612987dbe2a4e7f15bcaa0d4628dbae0d074dce5aeea2fb5e0efa
4
- data.tar.gz: 75a1ef4ea31451fccfb37369299d86afa1a08b26c825054de2eda109c636a561
3
+ metadata.gz: ac2c54a74f212244c74efeb88156c34398cdfe87751de14a597833533f4ccb92
4
+ data.tar.gz: 8f5614c2703ae6ee1440be65fa2c7654bc3cc12d2c8b1207f43a974ab804e3f2
5
5
  SHA512:
6
- metadata.gz: cba5c440d3da6c3a436b207d36203f07641ad1b76c61ade69ac5e39697c1b79a81374f7e7106a5764611b3f400f28f9dae89a7054a70c4124b6c21da0d6f8c8a
7
- data.tar.gz: e7cb123f63497d962297ea6629e684a0b8af6417c494a7332e02a9729585e977a0c02e2acdd6ee7eb99868fa70475b9a8e57e748eb38fde789957c66a8dc2afa
6
+ metadata.gz: 487d422ca183453e7e9a82de50aaa1c6e671383047838ca0acab124678520bf08edbe20a115f3c416407683e45b07cc5654b46bdcb71559004ef22271989d90b
7
+ data.tar.gz: ae81c712c9d31ffd440b8ad49200aea40c52251f8a6017344d0ce6bbd70b3b236ac89c850e2aac57c6866f87cec986a0c26694989b5f4b40eed7ffc147c1bbdb
@@ -5,6 +5,12 @@ on:
5
5
  push:
6
6
  branches: [main]
7
7
 
8
+ env:
9
+ # Hermetic CI: the corpus ref is pinned, never floating. Bump this
10
+ # one line deliberately with each corpus release (the tag carries
11
+ # what changed).
12
+ CORPUS_REF: v0.8.0
13
+
8
14
  jobs:
9
15
  test:
10
16
  runs-on: ubuntu-latest
@@ -18,12 +24,12 @@ jobs:
18
24
  uses: actions/checkout@v7
19
25
  with:
20
26
  repository: asciichem/asciichem-tests
27
+ ref: ${{ env.CORPUS_REF }}
21
28
  path: .asciichem-tests
22
29
  - name: Point the suite at the corpus and record its version
23
30
  run: |
24
31
  echo "ASCIICHEM_CORPUS=$(pwd)/.asciichem-tests/corpus/fixtures" >> "$GITHUB_ENV"
25
- git -C .asciichem-tests fetch --depth 1 --tags --quiet
26
- echo "ASCIICHEM_CORPUS_VERSION=$(git -C .asciichem-tests describe --tags --abbrev=0 2>/dev/null || echo main)" >> "$GITHUB_ENV"
32
+ echo "ASCIICHEM_CORPUS_VERSION=${{ env.CORPUS_REF }}" >> "$GITHUB_ENV"
27
33
  - uses: ruby/setup-ruby@v1
28
34
  with:
29
35
  ruby-version: ${{ matrix.ruby }}
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,50 @@
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.6] - 2026-10-21
7
+
8
+ ### Added
9
+ - **Mechanism wire ingestion** (TODO.v2/13 item 4): `from_model_json`
10
+ restores mechanisms — steps return as canonical reaction text, flows
11
+ and spectators intact; cascade steps have no wire form and raise
12
+ instead of silently flattening. Mechanism round-trip is asserted
13
+ corpus-wide (the `modelJsonRoundTrip` flag is gone).
14
+
15
+ ### Changed
16
+ - **BalanceCheck diagnostics carry structured details**: each
17
+ imbalance reports `element => [reactants, products]` deltas for
18
+ every element whose counts differ, alongside the message.
19
+ - **RInChI engine matches the real `rinchi_cmdline` contract**
20
+ (validated end-to-end against the IUPAC binary built from source):
21
+ the RXN file is the positional argument (no flags), direction-distinct
22
+ identities for a reaction and its reverse.
23
+ - Hermetic CI: the conformance corpus is pinned (`CORPUS_REF: v0.8.0`)
24
+ and the schema re-vendor script defaults to the released
25
+ asciichem-model tag (v0.6.0) — CI inputs no longer float at `main`.
26
+
27
+ ## [0.31.5] - 2026-10-20
28
+
29
+ ### Changed
30
+ - Aromaticity perception is now **Hückel counting** (TODO.v2/13 item 2,
31
+ finishing the documented later slice): 5- and 6-membered cycles
32
+ count π electrons — in-system double = 1, lone-pair donor
33
+ (N/O/S/P/Se/As) with no double = 2, exocyclic-double carbon = 0 —
34
+ and are aromatic at totals 6 or 10. Charged rings decline;
35
+ aromatic-flagged rings are taken as stated. Furan, pyrrole, and
36
+ thiophene are perceived from Kekulé bond orders; quinones remain
37
+ excluded, as do four-π and three-membered rings.
38
+ - Canonical SMILES covers the five-membered heteroaromatics:
39
+ `C1=COC=C1` → `c1cocc1`, `C1=CNC=C1` → `c1c[nH]cc1`,
40
+ `C1=CSC=C1` → `c1cscc1`. A perceived pyrrole nitrogen is written
41
+ `[nH]` (bare aromatic `n` implies no hydrogen); inferred heteroatom
42
+ hydrogens apply only to rings perceived from Kekulé orders, so
43
+ source-aromatic thiophene never gains a phantom H.
44
+ - Corpus contract 0.8.0 (`asciichem-tests`): Hückel fixtures for
45
+ furan/pyrrole/thiophene, cyclopentadiene and cyclopropene pinned as
46
+ not aromatic, plus `canonicalSmiles` expectations. TS and Python
47
+ parity merged; the Python writer also emits `H0` for stated-zero
48
+ hydrogens.
49
+
6
50
  ## [0.31.4] - 2026-10-20
7
51
 
8
52
  ### Changed
@@ -33,7 +33,8 @@ module AsciiChem
33
33
  diffs = compute_diffs(reactants, products)
34
34
  diagnostics << error(
35
35
  "Reaction is not balanced: #{diffs}",
36
- node: reaction
36
+ node: reaction,
37
+ details: element_deltas(reactants, products)
37
38
  )
38
39
  end
39
40
 
@@ -87,6 +88,16 @@ module AsciiChem
87
88
  result
88
89
  end
89
90
 
91
+ # Element => [reactant_count, product_count] for every element
92
+ # whose counts differ — the numbers behind the message.
93
+ def element_deltas(reactants, products)
94
+ (reactants.keys | products.keys).sort.each_with_object({}) do |element, out|
95
+ r = reactants.fetch(element, 0)
96
+ p = products.fetch(element, 0)
97
+ out[element] = [r, p] if r != p
98
+ end
99
+ end
100
+
90
101
  def compute_diffs(reactants, products)
91
102
  all_elements = (reactants.keys + products.keys).uniq.sort
92
103
  all_elements.map do |element|
@@ -22,16 +22,16 @@ module AsciiChem
22
22
 
23
23
  protected
24
24
 
25
- def error(message, node: nil)
26
- Diagnostic.new(severity: :error, message: message, node: node)
25
+ def error(message, node: nil, details: nil)
26
+ Diagnostic.new(severity: :error, message: message, node: node, details: details)
27
27
  end
28
28
 
29
- def warning(message, node: nil)
30
- Diagnostic.new(severity: :warning, message: message, node: node)
29
+ def warning(message, node: nil, details: nil)
30
+ Diagnostic.new(severity: :warning, message: message, node: node, details: details)
31
31
  end
32
32
 
33
- def info(message, node: nil)
34
- Diagnostic.new(severity: :info, message: message, node: node)
33
+ def info(message, node: nil, details: nil)
34
+ Diagnostic.new(severity: :info, message: message, node: node, details: details)
35
35
  end
36
36
 
37
37
  # Depth-first walk over every node in the formula. Yields each
@@ -2,7 +2,12 @@
2
2
 
3
3
  module AsciiChem
4
4
  module Linter
5
- Diagnostic = Struct.new(:severity, :message, :node, keyword_init: true) do
5
+ # `details` carries optional structured data behind the message
6
+ # (e.g. BalanceCheck's per-element [reactants, products] counts)
7
+ # for consumers that want the numbers, not the prose. Human-facing
8
+ # output stays the message.
9
+ Diagnostic = Struct.new(:severity, :message, :node, :details,
10
+ keyword_init: true) do
6
11
  def to_s
7
12
  context = node&.diagnostic_label
8
13
  if context
@@ -91,27 +91,56 @@ 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 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.
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).
105
115
  def aromatic_rings(atoms, edges)
106
116
  cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
107
117
  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) } }
118
+ cycles.select { |cycle| aromatic_cycle?(cycle, atoms, edges, cyclic_atoms) }
110
119
  end
111
120
 
112
- def sp2?(atom_index, atoms, edges, cyclic_atoms)
113
- 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)
114
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)
115
144
  edges.any? do |edge|
116
145
  next false unless edge.from == atom_index || edge.to == atom_index
117
146
  next false unless edge.kind == :double
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require 'tempfile'
4
5
 
5
6
  module AsciiChem
6
7
  module Rinchi
@@ -10,24 +11,28 @@ module AsciiChem
10
11
  # TODO.v2/10, a maintainer decision).
11
12
  #
12
13
  # AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new
13
- # AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new(bin: "/usr/local/bin/rinchi-1")
14
+ # AsciiChem::Rinchi.engine = AsciiChem::Rinchi::BinaryEngine.new(bin: "/usr/local/bin/rinchi_cmdline")
15
+ # The reference CLI (IUPAC-InChI/RInChI's `rinchi_cmdline`) takes a
16
+ # FILE argument, not stdin, needs no flags, and prints RInChI=,
17
+ # RAuxInfo=, Long-RInChIKey=, Short-RInChIKey= and Web-RInChIKey=
18
+ # lines. The engine writes the RXN to a temp file, runs the binary
19
+ # on it, and reads the identity lines back.
14
20
  class BinaryEngine
15
- DEFAULT_FLAGS = %w[-STDIO -AuxNone -NoLabels -Key].freeze
21
+ DEFAULT_FLAGS = [].freeze
16
22
 
17
23
  attr_reader :bin, :flags
18
24
 
19
- def initialize(bin: 'rinchi-1', flags: DEFAULT_FLAGS)
25
+ def initialize(bin: 'rinchi_cmdline', flags: DEFAULT_FLAGS)
20
26
  super()
21
27
  @bin = bin
22
28
  @flags = flags.freeze
23
29
  end
24
30
 
25
- # reaction → RXN → rinchi-1 → Identity. The RXN emission is the
26
- # pipe: reactants and products cross into the engine as V2000
31
+ # reaction → RXN → rinchi binary → Identity. The RXN emission is
32
+ # the pipe: reactants and products cross into the engine as V2000
27
33
  # mol blocks inside one $RXN container.
28
34
  def identity(reaction)
29
- rxn = AsciiChem::Rxn.write(reaction)
30
- out = execute(rxn)
35
+ out = execute(AsciiChem::Rxn.write(reaction))
31
36
  rinchi = out[/^RInChI=\S+/]
32
37
  raise AsciiChem::Error, "#{bin} produced no RInChI:\n#{out}" unless rinchi
33
38
 
@@ -40,16 +45,26 @@ module AsciiChem
40
45
 
41
46
  private
42
47
 
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]}"
48
+ def execute(rxn)
49
+ out = err = nil
50
+ status = nil
51
+ Tempfile.create(['asciichem', '.rxn']) do |file|
52
+ file.write(rxn)
53
+ file.flush
54
+ out, err, status = Open3.capture3(bin, file.path, *flags)
47
55
  end
48
-
56
+ report_failure(status, err, out)
49
57
  out
50
58
  rescue Errno::ENOENT
51
59
  raise EngineMissingError, "RInChI engine binary #{bin.inspect} not found — #{AsciiChem::Rinchi::INSTALL_GUIDE}"
52
60
  end
61
+
62
+ def report_failure(status, err, out)
63
+ return if status&.success?
64
+
65
+ raise AsciiChem::Error,
66
+ "#{bin} failed (exit #{status&.exitstatus}): #{((err || '') + (out || '')).strip[0, 500]}"
67
+ end
53
68
  end
54
69
  end
55
70
  end
@@ -15,9 +15,11 @@ module AsciiChem
15
15
  autoload :Identity, 'asciichem/rinchi/identity'
16
16
 
17
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")'
18
+ '(https://github.com/IUPAC-InChI/RInChI — build ' \
19
+ 'src/example_apps/rinchi_cmdline against InChI v1.07.5) ' \
20
+ 'and set AsciiChem::Rinchi.engine = ' \
21
+ 'AsciiChem::Rinchi::BinaryEngine.new ' \
22
+ '(or pass bin: "path/to/rinchi_cmdline")'
21
23
 
22
24
  class << self
23
25
  attr_accessor :engine
@@ -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
@@ -71,13 +75,27 @@ module AsciiChem
71
75
  # item 2): atoms in perceived aromatic rings are written as
72
76
  # aromatic (lowercase) and the ring bonds between them carry no
73
77
  # 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.
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.
76
85
  def perceive_aromaticity(atoms, edges)
77
86
  @perceived_aromatic = {}
78
87
  @perceived_ring_edges = {}
88
+ @perceived_hydrogens = {}
79
89
  AsciiChem::Perception.aromatic_rings(atoms, edges).each do |ring|
80
- ring.each { |index| @perceived_aromatic[index] = true }
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
81
99
  ring.each_with_index do |atom, i|
82
100
  other = ring[(i + 1) % ring.length]
83
101
  @perceived_ring_edges[[atom, other].minmax] = true
@@ -85,6 +103,19 @@ module AsciiChem
85
103
  end
86
104
  end
87
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
+
88
119
  # Aromatic for WRITING: the source flagged the atom, or
89
120
  # perception found it in an aromatic ring.
90
121
  def aromatic?(index)
@@ -269,8 +300,12 @@ module AsciiChem
269
300
  atom = @atoms[index]
270
301
  aromatic = aromatic?(index)
271
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)
272
306
  needs_bracket =
273
- atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
307
+ atom.charge || atom.isotope || !atom.hydrogens.nil? || atom.chirality ||
308
+ hetero_needs_h ||
274
309
  (!ORGANIC.include?(atom.element) && !lowercase) ||
275
310
  (aromatic && !lowercase)
276
311
  token =
@@ -280,10 +315,10 @@ module AsciiChem
280
315
  t << atom.isotope if atom.isotope
281
316
  t << symbol
282
317
  t << chirality_token(index) if stereo && atom.chirality
283
- if atom.hydrogens
318
+ if !atom.hydrogens.nil? || hetero_needs_h
284
319
  t << 'H'
285
320
  # Stated zero is H0, not a bare H (which means one).
286
- t << atom.hydrogens.to_s unless atom.hydrogens == 1
321
+ t << hyd.to_s unless hyd == 1
287
322
  end
288
323
  t << charge_suffix(atom.charge) if atom.charge
289
324
  t << ']'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.31.4"
4
+ VERSION = "0.31.6"
5
5
  end
@@ -9,17 +9,19 @@ module AsciiChem
9
9
  # fields.
10
10
  #
11
11
  # Emission (to_wire/to_model_json) covers every wire type.
12
- # Ingestion (from_wire/from_model_json) currently covers the
13
- # lossless core set: formula, atom, molecule, group, bond,
12
+ # Ingestion (from_wire/from_model_json) covers the lossless core
13
+ # set plus mechanism: formula, atom, molecule, group, bond,
14
14
  # identifier, name, reaction (+conditions), reaction-cascade,
15
- # electron-configuration, embedded-math, text. The beyond-formulas
16
- # nodes (mechanism, spectrum, crystal, zmatrix, calculation) are
15
+ # electron-configuration, embedded-math, text, mechanism (steps as
16
+ # canonical reaction text, flows, spectators; cascade steps have no
17
+ # wire form). Spectrum, crystal, zmatrix and calculation remain
17
18
  # emission-only until their round-trip acceptance lands in the
18
19
  # corpus.
19
20
  module WireAdapter
20
21
  CORE_INGEST_TYPES = %w[
21
22
  formula atom molecule group bond identifier name reaction
22
23
  reaction-cascade electron-configuration embedded-math text
24
+ mechanism
23
25
  ].freeze
24
26
 
25
27
  class << self
@@ -72,20 +74,27 @@ module AsciiChem
72
74
  # only routes `type` and re-injects routed children into
73
75
  # polymorphic collections (which lutaml deserialises flat).
74
76
  def wire_tree_from_hash(hash)
75
- type = hash.fetch("type") { raise KeyError, "wire node missing type discriminator" }
77
+ type = hash.fetch('type') { raise KeyError, 'wire node missing type discriminator' }
76
78
  klass = AsciiChem::Wire::REGISTRY.fetch(type) do
77
79
  raise KeyError, "unknown wire node type #{type.inspect}"
78
80
  end
79
81
 
80
82
  wire = klass.from_json(JSON.generate(hash))
81
83
  case type
82
- when "formula", "molecule", "group"
83
- wire.nodes = hash.fetch("nodes", []).map { |child| wire_tree_from_hash(child) }
84
- when "reaction"
85
- wire.reactants = hash.fetch("reactants", []).map { |child| wire_tree_from_hash(child) }
86
- wire.products = hash.fetch("products", []).map { |child| wire_tree_from_hash(child) }
87
- when "reaction-cascade"
88
- wire.steps = hash.fetch("steps", []).map { |child| wire_tree_from_hash(child) }
84
+ when 'formula', 'molecule', 'group'
85
+ wire.nodes = hash.fetch('nodes', []).map { |child| wire_tree_from_hash(child) }
86
+ when 'reaction'
87
+ wire.reactants = hash.fetch('reactants', []).map { |child| wire_tree_from_hash(child) }
88
+ wire.products = hash.fetch('products', []).map { |child| wire_tree_from_hash(child) }
89
+ when 'reaction-cascade'
90
+ wire.steps = hash.fetch('steps', []).map { |child| wire_tree_from_hash(child) }
91
+ when 'mechanism'
92
+ wire.steps = hash.fetch('steps', []).map do |step_hash|
93
+ step = AsciiChem::Wire::MechanismStep.from_json(JSON.generate(step_hash))
94
+ step.reaction = wire_tree_from_hash(step_hash.fetch('reaction'))
95
+ step
96
+ end
97
+ wire.spectators = hash.fetch('spectators', []).map { |child| wire_tree_from_hash(child) }
89
98
  end
90
99
  wire
91
100
  end
@@ -93,12 +102,12 @@ module AsciiChem
93
102
  private
94
103
 
95
104
  def formula_to_wire(node)
96
- AsciiChem::Wire::Formula.new(type: "formula", nodes: node.nodes.map { |n| to_wire(n) })
105
+ AsciiChem::Wire::Formula.new(type: 'formula', nodes: node.nodes.map { |n| to_wire(n) })
97
106
  end
98
107
 
99
108
  def atom_to_wire(atom)
100
109
  AsciiChem::Wire::Atom.new(
101
- type: "atom",
110
+ type: 'atom',
102
111
  element: atom.element,
103
112
  isotope: digits_or_nil(atom.isotope), charge: str_or_nil(atom.charge),
104
113
  subscript: digits_or_nil(atom.subscript),
@@ -134,7 +143,7 @@ module AsciiChem
134
143
 
135
144
  def molecule_to_wire(node)
136
145
  AsciiChem::Wire::Molecule.new(
137
- type: "molecule",
146
+ type: 'molecule',
138
147
  nodes: node.nodes.map { |n| to_wire(n) },
139
148
  coefficient: node.coefficient,
140
149
  stereo: stereo_to_wire(node.stereo),
@@ -158,7 +167,7 @@ module AsciiChem
158
167
 
159
168
  def group_to_wire(node)
160
169
  AsciiChem::Wire::Group.new(
161
- type: "group",
170
+ type: 'group',
162
171
  nodes: node.nodes.map { |n| to_wire(n) },
163
172
  multiplicity: node.multiplicity,
164
173
  bracket: node.bracket.to_s
@@ -166,27 +175,27 @@ module AsciiChem
166
175
  end
167
176
 
168
177
  def bond_to_wire(node)
169
- AsciiChem::Wire::Bond.new(type: "bond", kind: node.kind.to_s,
178
+ AsciiChem::Wire::Bond.new(type: 'bond', kind: node.kind.to_s,
170
179
  direction: str_or_nil(node.direction&.to_s))
171
180
  end
172
181
 
173
182
  def identifier_to_wire(node)
174
183
  AsciiChem::Wire::Identifier.new(
175
- type: "identifier",
184
+ type: 'identifier',
176
185
  value: node.value, convention: node.convention.to_s, dict_ref: node.dict_ref
177
186
  )
178
187
  end
179
188
 
180
189
  def name_to_wire(node)
181
190
  AsciiChem::Wire::Name.new(
182
- type: "name",
191
+ type: 'name',
183
192
  content: node.content, convention: node.convention, dict_ref: node.dict_ref
184
193
  )
185
194
  end
186
195
 
187
196
  def reaction_to_wire(node)
188
197
  AsciiChem::Wire::Reaction.new(
189
- type: "reaction",
198
+ type: 'reaction',
190
199
  reactants: node.reactants.map { |m| molecule_to_wire(m) },
191
200
  products: node.products.map { |m| molecule_to_wire(m) },
192
201
  arrow: node.arrow.to_s,
@@ -202,14 +211,14 @@ module AsciiChem
202
211
 
203
212
  def cascade_to_wire(node)
204
213
  AsciiChem::Wire::ReactionCascade.new(
205
- type: "reaction-cascade",
214
+ type: 'reaction-cascade',
206
215
  steps: node.steps.map { |r| reaction_to_wire(r) }
207
216
  )
208
217
  end
209
218
 
210
219
  def econfig_to_wire(node)
211
220
  AsciiChem::Wire::ElectronConfiguration.new(
212
- type: "electron-configuration",
221
+ type: 'electron-configuration',
213
222
  orbitals: node.orbitals.map do |(label, count)|
214
223
  AsciiChem::Wire::OrbitalOccupancy.new(orbital: label.to_s, occupancy: count.to_s)
215
224
  end,
@@ -226,16 +235,16 @@ module AsciiChem
226
235
  end
227
236
 
228
237
  def math_to_wire(node)
229
- AsciiChem::Wire::EmbeddedMath.new(type: "embedded-math", source: node.source)
238
+ AsciiChem::Wire::EmbeddedMath.new(type: 'embedded-math', source: node.source)
230
239
  end
231
240
 
232
241
  def text_to_wire(node)
233
- AsciiChem::Wire::Text.new(type: "text", content: node.content)
242
+ AsciiChem::Wire::Text.new(type: 'text', content: node.content)
234
243
  end
235
244
 
236
245
  def mechanism_to_wire(node)
237
246
  AsciiChem::Wire::Mechanism.new(
238
- type: "mechanism",
247
+ type: 'mechanism',
239
248
  steps: node.steps.map { |step| mechanism_step_to_wire(step) },
240
249
  spectators: node.spectators.map { |sp| mechanism_spectator_to_wire(sp) }
241
250
  )
@@ -264,12 +273,16 @@ module AsciiChem
264
273
 
265
274
  # The model keeps a step's reaction as TEXT (the mechanism body
266
275
  # is verbatim); the wire carries the typed node, so the same
267
- # grammar parses it here.
276
+ # grammar parses it here. A cascade step has no wire form —
277
+ # MechanismStep.reaction is typed Reaction — and says so instead
278
+ # of silently flattening the chain.
268
279
  def mechanism_reaction_to_wire(text)
269
280
  node = AsciiChem.parse(text).nodes.first
270
281
  case node
271
282
  when AsciiChem::Model::Reaction then reaction_to_wire(node)
272
- when AsciiChem::Model::ReactionCascade then cascade_to_wire(node)
283
+ when AsciiChem::Model::ReactionCascade
284
+ raise AsciiChem::ParseError,
285
+ "mechanism step cascades have no wire form: #{text.inspect}"
273
286
  else
274
287
  raise AsciiChem::ParseError,
275
288
  "mechanism step is not a reaction: #{text.inspect}"
@@ -278,7 +291,7 @@ module AsciiChem
278
291
 
279
292
  def spectrum_to_wire(node)
280
293
  AsciiChem::Wire::Spectrum.new(
281
- type: "spectrum",
294
+ type: 'spectrum',
282
295
  technique: node.type,
283
296
  params: node.params,
284
297
  peaks: node.peaks.map do |peak|
@@ -292,7 +305,7 @@ module AsciiChem
292
305
 
293
306
  def crystal_to_wire(node)
294
307
  AsciiChem::Wire::Crystal.new(
295
- type: "crystal",
308
+ type: 'crystal',
296
309
  name: node.name, a: node.a, b: node.b, c: node.c,
297
310
  alpha: node.alpha, beta: node.beta, gamma: node.gamma,
298
311
  spacegroup: node.spacegroup,
@@ -302,7 +315,7 @@ module AsciiChem
302
315
 
303
316
  def zmatrix_to_wire(node)
304
317
  AsciiChem::Wire::ZMatrix.new(
305
- type: "zmatrix",
318
+ type: 'zmatrix',
306
319
  rows: node.rows.map do |row|
307
320
  AsciiChem::Wire::ZRow.new(
308
321
  atom: row.atom, ref1: row.ref1, distance: row.distance,
@@ -314,7 +327,7 @@ module AsciiChem
314
327
 
315
328
  def calculation_to_wire(node)
316
329
  AsciiChem::Wire::Calculation.new(
317
- type: "calculation",
330
+ type: 'calculation',
318
331
  method: node.method, basis: node.basis,
319
332
  properties: node.properties.map do |prop|
320
333
  AsciiChem::Wire::CalculatedProperty.new(
@@ -394,6 +407,26 @@ module AsciiChem
394
407
  )
395
408
  end
396
409
 
410
+ def mechanism_from_wire(wire)
411
+ AsciiChem::Model::Mechanism.new(
412
+ steps: Array(wire.steps).map { |s| mechanism_step_from_wire(s) },
413
+ spectators: Array(wire.spectators).map { |sp| molecule_from_wire(sp).to_text }
414
+ )
415
+ end
416
+
417
+ # The model keeps a step's reaction as TEXT; the wire carries the
418
+ # typed node, so ingestion renders it back through the canonical
419
+ # text form.
420
+ def mechanism_step_from_wire(step)
421
+ AsciiChem::Model::Mechanism::Step.new(
422
+ label: step.label,
423
+ reaction: from_wire(step.reaction).to_text,
424
+ flows: Array(step.flows).map do |flow|
425
+ AsciiChem::Model::Mechanism::Flow.new(source: flow.source, target: flow.target)
426
+ end
427
+ )
428
+ end
429
+
397
430
  def electron_configuration_from_wire(wire)
398
431
  AsciiChem::Model::ElectronConfiguration.new(
399
432
  orbitals: Array(wire.orbitals).map { |o| [o.orbital, o.occupancy] },
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env bash
2
2
  # Re-vendor the asciichem-model v1 JSON Schemas (YAML) and examples.
3
- # Usage: scripts/update-model-schemas.sh [model-tag] (default: main).
3
+ # Usage: scripts/update-model-schemas.sh [model-tag]. Default is the
4
+ # released tag — vendored schemas stay hermetic, never floating.
4
5
  set -euo pipefail
5
6
 
6
- tag="${1:-main}"
7
+ tag="${1:-v0.6.0}"
7
8
  work="$(mktemp -d)"
8
9
  trap 'rm -rf "$work"' EXIT
9
10
 
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.4
4
+ version: 0.31.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.