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 +4 -4
- data/.github/workflows/ci.yml +8 -2
- data/CHANGELOG.md +49 -0
- data/lib/asciichem/inchi/binary_engine.rb +24 -9
- data/lib/asciichem/linter/balance_check.rb +12 -1
- data/lib/asciichem/linter/base.rb +6 -6
- data/lib/asciichem/linter/diagnostic.rb +6 -1
- data/lib/asciichem/molfile/parser.rb +16 -0
- data/lib/asciichem/molfile/writer.rb +37 -9
- data/lib/asciichem/rinchi/binary_engine.rb +27 -12
- data/lib/asciichem/rinchi.rb +5 -3
- data/lib/asciichem/structure/graph.rb +64 -5
- data/lib/asciichem/version.rb +1 -1
- data/lib/asciichem/wire_adapter.rb +64 -31
- data/scripts/update-model-schemas.sh +3 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 64f4629dc9bbc023f63f306a064c9657564557ed8c4b52bbda18ca15b253aaeb
|
|
4
|
+
data.tar.gz: f9f3534aada7d42cd45c22469557e554cd6ea1d2e35af17229fd306140dff24c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5a172b80e7f6d433f20da0c9d741f999f84699ce9a71b0c96319d30ce8e73928dfcd7ac5e02eebd61466a3500430f64733007c7714ffd7a87ea0b3c4d0e34464
|
|
7
|
+
data.tar.gz: 367240a813ebb1fd22f35dd4421245f5d66f57c4af3000ed51c874b664d4fb4bb9ddf665a3e956bcfef74fc61767ed0711e1cc8c0d2df9db4239be9dcc3a0aa0
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
10
|
-
#
|
|
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[-
|
|
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(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
12
|
-
# (TH1 →
|
|
13
|
-
# the
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
|
-
|
|
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/
|
|
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 =
|
|
21
|
+
DEFAULT_FLAGS = [].freeze
|
|
16
22
|
|
|
17
23
|
attr_reader :bin, :flags
|
|
18
24
|
|
|
19
|
-
def initialize(bin: '
|
|
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
|
|
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
|
-
|
|
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(
|
|
44
|
-
out
|
|
45
|
-
|
|
46
|
-
|
|
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
|
data/lib/asciichem/rinchi.rb
CHANGED
|
@@ -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
|
|
19
|
-
'
|
|
20
|
-
'
|
|
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
|
-
#
|
|
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
|
|
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
|
data/lib/asciichem/version.rb
CHANGED
|
@@ -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)
|
|
13
|
-
#
|
|
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
|
|
16
|
-
#
|
|
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(
|
|
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
|
|
83
|
-
wire.nodes = hash.fetch(
|
|
84
|
-
when
|
|
85
|
-
wire.reactants = hash.fetch(
|
|
86
|
-
wire.products = hash.fetch(
|
|
87
|
-
when
|
|
88
|
-
wire.steps = hash.fetch(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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]
|
|
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:-
|
|
7
|
+
tag="${1:-v0.6.0}"
|
|
7
8
|
work="$(mktemp -d)"
|
|
8
9
|
trap 'rm -rf "$work"' EXIT
|
|
9
10
|
|