asciichem 0.31.5 → 0.31.7

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: 6c9a131b0a052014c60f5be8b74972de24db882f7db088b82bdea1c506709a46
4
- data.tar.gz: 9b280851bdc1fb8c4f4681f1887796f2eb688298f8c495cd81a766a879fd8a09
3
+ metadata.gz: 64f4629dc9bbc023f63f306a064c9657564557ed8c4b52bbda18ca15b253aaeb
4
+ data.tar.gz: f9f3534aada7d42cd45c22469557e554cd6ea1d2e35af17229fd306140dff24c
5
5
  SHA512:
6
- metadata.gz: d5580a3f5f314447b3f1f932b37396bb5741bd5363d96e4ae97cc95fcbcf7cfbcd0690ae16253c532d21b2c790093fda0652cd3d1a330f3bcbce9e1801c66e66
7
- data.tar.gz: 44a0d45dfca800429e648a5a3c3adab685c5813c7c23d5be68afb55c7dc4cf34410b8cb16ff43681a439d9ec258c7e4f71aa141e7d3561a6a05c0ccc2cbc3f5b
6
+ metadata.gz: 5a172b80e7f6d433f20da0c9d741f999f84699ce9a71b0c96319d30ce8e73928dfcd7ac5e02eebd61466a3500430f64733007c7714ffd7a87ea0b3c4d0e34464
7
+ data.tar.gz: 367240a813ebb1fd22f35dd4421245f5d66f57c4af3000ed51c874b664d4fb4bb9ddf665a3e956bcfef74fc61767ed0711e1cc8c0d2df9db4239be9dcc3a0aa0
@@ -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/CHANGELOG.md CHANGED
@@ -3,6 +3,55 @@
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.7] - 2026-10-22
7
+
8
+ ### Fixed
9
+ - **InChI engine matches the distributed binary's real contract**
10
+ (found by the TODO.v2/16 oracle work): `-STDIO` is not part of the
11
+ standard `inchi-1` interface — the engine now stages the molfile to
12
+ a temp file and reads the output file, the same shape as the RInChI
13
+ engine. Verified against the brew 1.07.5 build.
14
+ - **Tetrahedral stereo actually reaches the oracle now**: the writer
15
+ emits a wedge on the stereocentre's first bond (TH1 up, TH2 down);
16
+ the V2000 atom-parity field alone is ignored by `inchi-1` for 2D
17
+ input, so the previous parity-only claim never produced stereo
18
+ InChIs. Enantiomers now derive distinct standard InChIs
19
+ (`N[C@H](C)C(=O)O` → the published L-alanine key), gated by the
20
+ real-binary spec (`ASCIICHEM_INCHI_BIN`).
21
+ - **Molfile round-trip folds chirality wedges back**: a wedge/hash
22
+ bond on a parity-chiral atom reads as the plain single bond it was
23
+ written from, instead of re-entering the model as a wedge/hash
24
+ bond kind.
25
+ - **Structure writers decline formula-style adjacency instead of
26
+ silently fragmenting**: `CCO` and undashed mixtures
27
+ (`C1=CC=CC=C1` — only its double bonds + one ring closure are
28
+ explicit) used to write disconnected graphs and garbage SMILES;
29
+ `Structure::Graph.build!` now declines with guidance while the
30
+ tolerant `build` stays available to the linter. SMILES-ingested
31
+ models are unaffected (their branch reconnection crosses as a
32
+ ring-closure digit, so the graph is connected).
33
+
34
+ ## [0.31.6] - 2026-10-21
35
+
36
+ ### Added
37
+ - **Mechanism wire ingestion** (TODO.v2/13 item 4): `from_model_json`
38
+ restores mechanisms — steps return as canonical reaction text, flows
39
+ and spectators intact; cascade steps have no wire form and raise
40
+ instead of silently flattening. Mechanism round-trip is asserted
41
+ corpus-wide (the `modelJsonRoundTrip` flag is gone).
42
+
43
+ ### Changed
44
+ - **BalanceCheck diagnostics carry structured details**: each
45
+ imbalance reports `element => [reactants, products]` deltas for
46
+ every element whose counts differ, alongside the message.
47
+ - **RInChI engine matches the real `rinchi_cmdline` contract**
48
+ (validated end-to-end against the IUPAC binary built from source):
49
+ the RXN file is the positional argument (no flags), direction-distinct
50
+ identities for a reaction and its reverse.
51
+ - Hermetic CI: the conformance corpus is pinned (`CORPUS_REF: v0.8.0`)
52
+ and the schema re-vendor script defaults to the released
53
+ asciichem-model tag (v0.6.0) — CI inputs no longer float at `main`.
54
+
6
55
  ## [0.31.5] - 2026-10-20
7
56
 
8
57
  ### Changed
@@ -6,13 +6,20 @@ module AsciiChem
6
6
  module Inchi
7
7
  # Engine that shells out to the standalone IUPAC `inchi-1`
8
8
  # binary. The binary is a documented external dependency — nothing
9
- # is vendored (vendoring the InChI library requires the licence
10
- # review recorded in TODO.v2 10, a maintainer decision).
9
+ # is vendored (TODO.v2 16 keeps it that way; the native path is a
10
+ # separate engine).
11
+ #
12
+ # Contract: the distributed binary's file interface
13
+ # (`inchi-1 input.mol output.txt [flags]`). `-STDIO` is NOT part
14
+ # of it — the standard distribution ignores it and prints usage
15
+ # (caught against the brew 1.07.5 build), so the engine stages the
16
+ # molfile to a temp file and reads the output file back, the same
17
+ # shape as the RInChI engine.
11
18
  #
12
19
  # AsciiChem::Inchi.engine = AsciiChem::Inchi::BinaryEngine.new
13
20
  # AsciiChem::Inchi.engine = AsciiChem::Inchi::BinaryEngine.new(bin: "/usr/local/bin/inchi-1")
14
21
  class BinaryEngine < Engine
15
- DEFAULT_FLAGS = %w[-STDIO -AuxNone -NoLabels -Key].freeze
22
+ DEFAULT_FLAGS = %w[-AuxNone -NoLabels -Key].freeze
16
23
 
17
24
  attr_reader :bin, :flags
18
25
 
@@ -39,13 +46,21 @@ module AsciiChem
39
46
 
40
47
  private
41
48
 
42
- def execute(input)
43
- out, err, status = Open3.capture3(bin, *flags, stdin_data: input)
44
- unless status.success?
45
- raise AsciiChem::Error, "#{bin} failed (exit #{status.exitstatus}): #{(err + out).strip[0, 500]}"
46
- end
49
+ def execute(molfile)
50
+ require 'tmpdir'
51
+ Dir.mktmpdir('asciichem-inchi') do |dir|
52
+ input = File.join(dir, 'structure.mol')
53
+ output = File.join(dir, 'structure.txt')
54
+ File.write(input, molfile)
55
+ _out, err, status = Open3.capture3(bin, input, output, *flags)
56
+ unless status.success?
57
+ raise AsciiChem::Error, "#{bin} failed (exit #{status.exitstatus}): #{err.strip[0, 500]}"
58
+ end
47
59
 
48
- out
60
+ # A clean run with no output file means no structure output;
61
+ # the caller's InChI= regex raises the no-InChI error.
62
+ File.exist?(output) ? File.read(output) : ''
63
+ end
49
64
  rescue Errno::ENOENT
50
65
  raise EngineMissingError, "InChI engine binary #{bin.inspect} not found — #{AsciiChem::Inchi::INSTALL_GUIDE}"
51
66
  end
@@ -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
@@ -26,6 +26,22 @@ module AsciiChem
26
26
  bonds = parse_bonds(bond_count)
27
27
  properties = parse_properties
28
28
 
29
+ # A wedge/hash bond on an atom whose parity field already
30
+ # carries chirality is that chirality's wedge (our writer
31
+ # emits exactly that pair), not an independent bond kind —
32
+ # fold it back to its plain single bond.
33
+ chiral = atoms.each_with_index
34
+ .select { |atom, _| atom.chirality }
35
+ .map { |_, i| i }
36
+ bonds = bonds.map do |bond|
37
+ if %i[wedge hash].include?(bond[:kind]) &&
38
+ (chiral.include?(bond[:from]) || chiral.include?(bond[:to]))
39
+ bond.merge(kind: :single)
40
+ else
41
+ bond
42
+ end
43
+ end
44
+
29
45
  apply_legacy_charge!(atoms)
30
46
  apply_charges!(atoms, properties[:charges])
31
47
  apply_isotopes!(atoms, properties[:isotopes])
@@ -8,13 +8,14 @@ module AsciiChem
8
8
  # Structure::Graph, so positions map by index). Charges and
9
9
  # isotopes are emitted as `M CHG` / `M ISO` property lines.
10
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.
11
+ # Tetrahedral chirality crosses as a WEDGE on the stereocentre's
12
+ # first bond (TH1 → up, TH2 → down), oriented centre → neighbour:
13
+ # the oracle ignores the V2000 atom-parity field for 2D input
14
+ # (verified against inchi-1 1.07.5 — enantiomers only separate
15
+ # through the wedge), so the parity field alone never carries
16
+ # stereo. Directional bonds (`/`, `\`, E/Z) have no V2000 field:
17
+ # the model's Bond#direction is documented as a molfile loss
18
+ # rather than silently invented from geometry.
18
19
  class Writer
19
20
  BOND_TYPES = {
20
21
  single: 1, double: 2, triple: 3, aromatic: 4,
@@ -29,7 +30,7 @@ module AsciiChem
29
30
  end
30
31
 
31
32
  def write
32
- atoms, edges = Structure::Graph.build(@molecule)
33
+ atoms, edges = Structure::Graph.build!(@molecule)
33
34
  raise ParseError, "molecule has no bonds — a formula is not a structure" if edges.empty? && atoms.length > 1
34
35
 
35
36
  lines = []
@@ -47,13 +48,18 @@ module AsciiChem
47
48
  hcount_code(atom))
48
49
  end
49
50
 
51
+ wedges = wedge_bonds(atoms, edges)
50
52
  edges.each do |edge|
51
53
  type = BOND_TYPES.fetch(edge.kind) do
52
54
  raise ParseError, "#{edge.kind} bonds have no molfile V2000 type"
53
55
  end
54
56
  stereo = BOND_STEREO.fetch(edge.kind, 0)
57
+ from, to = edge.from, edge.to
58
+ if (wedge = wedges[edge.object_id])
59
+ from, to, stereo = wedge
60
+ end
55
61
  lines << format("%3d%3d%3d%3d 0 0 0 0 0 0 0",
56
- edge.from + 1, edge.to + 1, type, stereo)
62
+ from + 1, to + 1, type, stereo)
57
63
  end
58
64
 
59
65
  lines << property_line("M CHG", charge_pairs(atoms))
@@ -64,6 +70,28 @@ module AsciiChem
64
70
 
65
71
  private
66
72
 
73
+ # One wedge per chiral atom, on its first bond, oriented
74
+ # centre → neighbour: TH1 rises (stereo 1), TH2 falls (6).
75
+ # Returns { edge.object_id => [from, to, stereo] }.
76
+ def wedge_bonds(atoms, edges)
77
+ wedges = {}
78
+ atoms.each_with_index do |atom, index|
79
+ next unless CHIRALITY_PARITY.key?(atom.chirality)
80
+
81
+ edge = edges.find { |e| e.from == index || e.to == index }
82
+ next unless edge
83
+
84
+ stereo = atom.chirality == 'TH1' ? 1 : 6
85
+ wedges[edge.object_id] =
86
+ if edge.from == index
87
+ [index, edge.to, stereo]
88
+ else
89
+ [index, edge.from, stereo]
90
+ end
91
+ end
92
+ wedges
93
+ end
94
+
67
95
  # MDL H-count code: 0 default, 1 H0, 2 H1, 3 H2, ... — so a
68
96
  # stated count n crosses as n + 1.
69
97
  def hcount_code(atom)
@@ -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
@@ -9,12 +9,46 @@ module AsciiChem
9
9
  Edge = Struct.new(:from, :to, :kind, :direction, keyword_init: true)
10
10
 
11
11
  class << self
12
- # Returns [atoms, edges] where atoms is the atom array in
13
- # walk order and edges is an array of Edge with array indexes.
12
+ # Returns [atoms, edges] where atoms is the atom array in walk
13
+ # order and edges is an array of Edge with array indexes.
14
+ #
15
+ # Tolerant on purpose: the linter counts exactly the bonds that
16
+ # are written. Structure writers use `build!`, which declines
17
+ # formula-style adjacency (see there).
14
18
  def build(molecule)
19
+ atoms, edges, = build_with_adjacency(molecule)
20
+ [atoms, edges]
21
+ end
22
+
23
+ # Structure writers only. Text adjacency — atoms with no bond
24
+ # token between them (`CCO`, the undashed spacers in
25
+ # `C1=CC=CC=C1`) — is formula notation, not a bond; mixing it
26
+ # with explicit bonds silently fragments the graph (a benzene
27
+ # ring written as a disconnected C4+C2 pair), so the strict
28
+ # build declines when adjacency is present and the final graph
29
+ # is disconnected. SMILES models never trip this: their parser
30
+ # always bonds every continuation (branch reconnection crosses
31
+ # as a ring-closure digit), so the graph is connected even
32
+ # though the flat node list has token-less boundaries — and an
33
+ # explicit multi-component model (a salt written `A.B`) has no
34
+ # adjacency at all.
35
+ def build!(molecule)
36
+ atoms, edges, adjacency_pairs = build_with_adjacency(molecule)
37
+ return [atoms, edges] if adjacency_pairs.empty? || connected?(atoms.length, edges)
38
+
39
+ raise ParseError,
40
+ 'adjacent atoms have no explicit bond token — a formula ' \
41
+ 'is not a structure. Write explicit bonds (C-C) or ingest ' \
42
+ 'the structure from SMILES/molfile.'
43
+ end
44
+
45
+ private
46
+
47
+ def build_with_adjacency(molecule)
15
48
  atoms = []
16
49
  edges = []
17
50
  index_by_object_id = {}
51
+ adjacency_pairs = []
18
52
  pending = nil
19
53
  last = nil
20
54
 
@@ -28,9 +62,11 @@ module AsciiChem
28
62
  # same as the Layout walker.
29
63
  index_by_object_id[node.object_id] = index
30
64
  atoms << node
31
- if pending && last
65
+ if pending
32
66
  edges << Edge.new(from: last, to: index, kind: pending.kind,
33
- direction: pending.direction)
67
+ direction: pending.direction) if last
68
+ elsif last
69
+ adjacency_pairs << [last, index]
34
70
  end
35
71
  last = index
36
72
  pending = nil
@@ -55,7 +91,30 @@ module AsciiChem
55
91
  edges << Edge.new(from: from, to: to, kind: kind)
56
92
  end
57
93
 
58
- [atoms, edges]
94
+ [atoms, edges, adjacency_pairs]
95
+ end
96
+
97
+ def connected?(atom_count, edges)
98
+ return true if atom_count <= 1
99
+
100
+ neighbours = Array.new(atom_count) { [] }
101
+ edges.each do |edge|
102
+ neighbours[edge.from] << edge.to
103
+ neighbours[edge.to] << edge.from
104
+ end
105
+ seen = [false] * atom_count
106
+ seen[0] = true
107
+ queue = [0]
108
+ until queue.empty?
109
+ current = queue.shift
110
+ neighbours[current].each do |n|
111
+ next if seen[n]
112
+
113
+ seen[n] = true
114
+ queue << n
115
+ end
116
+ end
117
+ seen.all?
59
118
  end
60
119
  end
61
120
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.31.5"
4
+ VERSION = "0.31.7"
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.5
4
+ version: 0.31.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.