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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ac2c54a74f212244c74efeb88156c34398cdfe87751de14a597833533f4ccb92
4
- data.tar.gz: 8f5614c2703ae6ee1440be65fa2c7654bc3cc12d2c8b1207f43a974ab804e3f2
3
+ metadata.gz: 040c17f14243ee3dfa4ecb2c5db7f961aa91a10768118977cd01d48a209650a7
4
+ data.tar.gz: 9320019d60542569b130dd57e332b5e9afe4ff6641254d680e73f2b965977ecf
5
5
  SHA512:
6
- metadata.gz: 487d422ca183453e7e9a82de50aaa1c6e671383047838ca0acab124678520bf08edbe20a115f3c416407683e45b07cc5654b46bdcb71559004ef22271989d90b
7
- data.tar.gz: ae81c712c9d31ffd440b8ad49200aea40c52251f8a6017344d0ce6bbd70b3b236ac89c850e2aac57c6866f87cec986a0c26694989b5f4b40eed7ffc147c1bbdb
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 (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
@@ -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)
@@ -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
- # 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.6"
4
+ VERSION = "0.31.8"
5
5
  end
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.6
4
+ version: 0.31.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.