asciichem 0.31.6 → 0.31.8
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/CHANGELOG.md +36 -0
- data/lib/asciichem/inchi/binary_engine.rb +24 -9
- data/lib/asciichem/molfile/parser.rb +16 -0
- data/lib/asciichem/molfile/writer.rb +37 -9
- data/lib/asciichem/smiles/writer.rb +1 -1
- data/lib/asciichem/structure/graph.rb +64 -5
- data/lib/asciichem/version.rb +1 -1
- 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: 040c17f14243ee3dfa4ecb2c5db7f961aa91a10768118977cd01d48a209650a7
|
|
4
|
+
data.tar.gz: 9320019d60542569b130dd57e332b5e9afe4ff6641254d680e73f2b965977ecf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7a923467ce4e6e857f4175c5c335de6bb1116c5426c78828b328fccc6621a6c9313809ee93d82f2de1a35fc14114f1d77a1220f453e4c59af2fa3eac6e5c4aa2
|
|
7
|
+
data.tar.gz: 63b55738186c1f4b2e98bd38b71b2af97b01946ee9715d44f934c364df26b1f1b79e29d9e072c34cd264e384f475fa0b4e8ce64719a2d7844a3e9db5f8da489f
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
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.8] - 2026-10-22
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
- The SMILES writer joins the strict structure decline (a line
|
|
10
|
+
missed in 0.31.7's staging): `to_smiles` no longer silently
|
|
11
|
+
fragments undashed text — `C1=CC=CC=C1` declines with guidance
|
|
12
|
+
like the molfile writer.
|
|
13
|
+
|
|
14
|
+
## [0.31.7] - 2026-10-22
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **InChI engine matches the distributed binary's real contract**
|
|
18
|
+
(found by the TODO.v2/16 oracle work): `-STDIO` is not part of the
|
|
19
|
+
standard `inchi-1` interface — the engine now stages the molfile to
|
|
20
|
+
a temp file and reads the output file, the same shape as the RInChI
|
|
21
|
+
engine. Verified against the brew 1.07.5 build.
|
|
22
|
+
- **Tetrahedral stereo actually reaches the oracle now**: the writer
|
|
23
|
+
emits a wedge on the stereocentre's first bond (TH1 up, TH2 down);
|
|
24
|
+
the V2000 atom-parity field alone is ignored by `inchi-1` for 2D
|
|
25
|
+
input, so the previous parity-only claim never produced stereo
|
|
26
|
+
InChIs. Enantiomers now derive distinct standard InChIs
|
|
27
|
+
(`N[C@H](C)C(=O)O` → the published L-alanine key), gated by the
|
|
28
|
+
real-binary spec (`ASCIICHEM_INCHI_BIN`).
|
|
29
|
+
- **Molfile round-trip folds chirality wedges back**: a wedge/hash
|
|
30
|
+
bond on a parity-chiral atom reads as the plain single bond it was
|
|
31
|
+
written from, instead of re-entering the model as a wedge/hash
|
|
32
|
+
bond kind.
|
|
33
|
+
- **Structure writers decline formula-style adjacency instead of
|
|
34
|
+
silently fragmenting**: `CCO` and undashed mixtures
|
|
35
|
+
(`C1=CC=CC=C1` — only its double bonds + one ring closure are
|
|
36
|
+
explicit) used to write disconnected graphs and garbage SMILES;
|
|
37
|
+
`Structure::Graph.build!` now declines with guidance while the
|
|
38
|
+
tolerant `build` stays available to the linter. SMILES-ingested
|
|
39
|
+
models are unaffected (their branch reconnection crosses as a
|
|
40
|
+
ring-closure digit, so the graph is connected).
|
|
41
|
+
|
|
6
42
|
## [0.31.6] - 2026-10-21
|
|
7
43
|
|
|
8
44
|
### Added
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -34,7 +34,7 @@ module AsciiChem
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def write
|
|
37
|
-
atoms, edges = Structure::Graph.build(@molecule)
|
|
37
|
+
atoms, edges = Structure::Graph.build!(@molecule)
|
|
38
38
|
# A single-atom component is valid SMILES (water "O", ions);
|
|
39
39
|
# multiple atoms with no bonds is a formula, not a structure.
|
|
40
40
|
raise ParseError, 'molecule has no bonds — a formula is not a structure' if edges.empty? && atoms.length > 1
|
|
@@ -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