asciichem 0.31.4 → 0.31.5
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/ARCHITECTURE.adoc +78 -0
- data/CHANGELOG.md +23 -0
- data/lib/asciichem/perception.rb +44 -15
- data/lib/asciichem/smiles/writer.rb +41 -6
- 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: 6c9a131b0a052014c60f5be8b74972de24db882f7db088b82bdea1c506709a46
|
|
4
|
+
data.tar.gz: 9b280851bdc1fb8c4f4681f1887796f2eb688298f8c495cd81a766a879fd8a09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d5580a3f5f314447b3f1f932b37396bb5741bd5363d96e4ae97cc95fcbcf7cfbcd0690ae16253c532d21b2c790093fda0652cd3d1a330f3bcbce9e1801c66e66
|
|
7
|
+
data.tar.gz: 44a0d45dfca800429e648a5a3c3adab685c5813c7c23d5be68afb55c7dc4cf34410b8cb16ff43681a439d9ec258c7e4f71aa141e7d3561a6a05c0ccc2cbc3f5b
|
data/ARCHITECTURE.adoc
CHANGED
|
@@ -390,3 +390,81 @@ Ten registered checks (auto-load via `Linter::Base.register`):
|
|
|
390
390
|
* `#atom_count` — total atoms (recurses through groups/nested molecules)
|
|
391
391
|
* `#hill_formula` — Hill-system canonical formula (C first, H second, alpha)
|
|
392
392
|
* `#formula_weight` — sum of atomic_mass * count (v0.13+)
|
|
393
|
+
|
|
394
|
+
== Perception pass (v0.31+)
|
|
395
|
+
|
|
396
|
+
`AsciiChem::Perception` derives the chemistry the syntax leaves
|
|
397
|
+
implicit, as pure functions over the neutral graph pair
|
|
398
|
+
(`Structure::Graph.build` returns `[atoms, edges]`):
|
|
399
|
+
|
|
400
|
+
* `bond_order_sum` — valence-feasibility sum (aromatic orders as 1);
|
|
401
|
+
consumed by the linter's `ValenceCheck`.
|
|
402
|
+
* `hydrogen_valence_sum` — hydrogen-counting sum (aromatic orders as
|
|
403
|
+
1.5, Hückel); paired with `implicit_hydrogens`, which returns a
|
|
404
|
+
whole, non-negative count or declines (`nil`): stated hydrogens win,
|
|
405
|
+
charged atoms and non-integral/over-valent sums decline.
|
|
406
|
+
* `aromatic_rings` — π-electron counting for 5- and 6-membered cycles
|
|
407
|
+
(in-system double = 1, lone-pair donor N/O/S/P/Se/As = 2,
|
|
408
|
+
exocyclic-double carbon = 0; aromatic at totals 6 or 10). Charged
|
|
409
|
+
rings decline; aromatic-flagged rings are taken as stated. This
|
|
410
|
+
perceives furan/pyrrole/thiophene, fused systems, keeps quinones
|
|
411
|
+
Kekulé, and rejects four-π and three-membered rings.
|
|
412
|
+
|
|
413
|
+
Semantics are shared through the corpus (`fixtures/perception.json`) —
|
|
414
|
+
per-atom implicit hydrogens plus perceived ring indexes, declines
|
|
415
|
+
included — and mirrored in TypeScript and Python.
|
|
416
|
+
|
|
417
|
+
== Canonical SMILES (v0.31+)
|
|
418
|
+
|
|
419
|
+
`Smiles::Writer` is a deterministic serialiser that consumes the
|
|
420
|
+
perception pass: perceived aromatic rings are written lowercase with
|
|
421
|
+
their ring bonds implicit, so Kekulé input canonicalizes
|
|
422
|
+
(`C1=CC=CC=C1` → `c1ccccc1`, pyrrole → `c1c[nH]cc1`). Ring-closure
|
|
423
|
+
digits stay out of branches (a closure-bearing child continues the
|
|
424
|
+
chain). Stated-zero hydrogens emit `H0`. The canonical form is a fixed
|
|
425
|
+
point; corpus fixtures whose input is not canonical state the expected
|
|
426
|
+
output in `canonicalSmiles`.
|
|
427
|
+
|
|
428
|
+
== Reactions as semantics (v0.31+)
|
|
429
|
+
|
|
430
|
+
* `Reaction::Conditions#entries` — typed role entries
|
|
431
|
+
(`[cat: Pd; solv: THF]`) derived from the verbatim bracket text;
|
|
432
|
+
`ReactionRoles.roles_for` prefers explicit roles, falls back to the
|
|
433
|
+
above/below convention for free text.
|
|
434
|
+
* `Reaction#stoichiometry` and `Molecule#stoichiometric_coefficient` —
|
|
435
|
+
coefficient-free species → integer coefficient, one rule shared by
|
|
436
|
+
the reaction, `BalanceCheck`, and `element_counts`.
|
|
437
|
+
* `MechanismGraph.for(mechanism)` — parsed steps, catalysts (species on
|
|
438
|
+
both sides), inter-step links (product → later reactant), and the
|
|
439
|
+
step's electron-flow markers.
|
|
440
|
+
* `Mechanism::Flow` — `| flow: SOURCE > TARGET` clauses on a step
|
|
441
|
+
(semicolon-separated), verbatim names, carried on the wire as
|
|
442
|
+
`steps[].flows`; unknown clauses and malformed arrows raise.
|
|
443
|
+
* `Rxn::Writer` — MDL RXN V2000 emission (species, not
|
|
444
|
+
stoichiometry); `Rinchi::BinaryEngine` wraps the opt-in `rinchi-1`
|
|
445
|
+
binary for reaction identity (same contract as the InChI engine:
|
|
446
|
+
install guidance, `EngineMissingError`, nothing vendored).
|
|
447
|
+
|
|
448
|
+
== Depiction backends (v0.31+)
|
|
449
|
+
|
|
450
|
+
`AsciiChem::Layout.backend` is the depiction seam: a backend responds
|
|
451
|
+
to `layout(molecule, algorithm:)` and returns a `Layout::Result`.
|
|
452
|
+
`Layout::BuiltinBackend` (authored coordinates or deterministic elkrb
|
|
453
|
+
layered layout) is the default; external engines (RDKit, CDK, a Rust
|
|
454
|
+
layout core) plug in with one assignment. The renderer, the model, and
|
|
455
|
+
the wire form never learn which engine placed the atoms.
|
|
456
|
+
|
|
457
|
+
== Conformance contracts (v0.31+)
|
|
458
|
+
|
|
459
|
+
Two sibling repositories are normative, not the implementation:
|
|
460
|
+
|
|
461
|
+
* `asciichem-model` — versioned wire schemas (additive changes only),
|
|
462
|
+
LUTAML definitions, generated TS types; the gem vendors the schemas
|
|
463
|
+
into `spec/schemas` via `scripts/update-model-schemas.sh`.
|
|
464
|
+
* `asciichem-tests` — the shared corpus. Fixture families: parser
|
|
465
|
+
acceptance/rejection, canonical-text round-trip, CML, MathML
|
|
466
|
+
goldens, identifiers, linter expectations, SMILES/molfile
|
|
467
|
+
interchange, perception (`implicitHydrogens`, `aromaticRings`),
|
|
468
|
+
condition entries, mechanism flows, and 300 seeded generated models
|
|
469
|
+
shared across implementations. Contract changes are versioned and
|
|
470
|
+
state new expectations; fixtures are never weakened.
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,29 @@
|
|
|
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.5] - 2026-10-20
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
- Aromaticity perception is now **Hückel counting** (TODO.v2/13 item 2,
|
|
10
|
+
finishing the documented later slice): 5- and 6-membered cycles
|
|
11
|
+
count π electrons — in-system double = 1, lone-pair donor
|
|
12
|
+
(N/O/S/P/Se/As) with no double = 2, exocyclic-double carbon = 0 —
|
|
13
|
+
and are aromatic at totals 6 or 10. Charged rings decline;
|
|
14
|
+
aromatic-flagged rings are taken as stated. Furan, pyrrole, and
|
|
15
|
+
thiophene are perceived from Kekulé bond orders; quinones remain
|
|
16
|
+
excluded, as do four-π and three-membered rings.
|
|
17
|
+
- Canonical SMILES covers the five-membered heteroaromatics:
|
|
18
|
+
`C1=COC=C1` → `c1cocc1`, `C1=CNC=C1` → `c1c[nH]cc1`,
|
|
19
|
+
`C1=CSC=C1` → `c1cscc1`. A perceived pyrrole nitrogen is written
|
|
20
|
+
`[nH]` (bare aromatic `n` implies no hydrogen); inferred heteroatom
|
|
21
|
+
hydrogens apply only to rings perceived from Kekulé orders, so
|
|
22
|
+
source-aromatic thiophene never gains a phantom H.
|
|
23
|
+
- Corpus contract 0.8.0 (`asciichem-tests`): Hückel fixtures for
|
|
24
|
+
furan/pyrrole/thiophene, cyclopentadiene and cyclopropene pinned as
|
|
25
|
+
not aromatic, plus `canonicalSmiles` expectations. TS and Python
|
|
26
|
+
parity merged; the Python writer also emits `H0` for stated-zero
|
|
27
|
+
hydrogens.
|
|
28
|
+
|
|
6
29
|
## [0.31.4] - 2026-10-20
|
|
7
30
|
|
|
8
31
|
### Changed
|
data/lib/asciichem/perception.rb
CHANGED
|
@@ -91,27 +91,56 @@ module AsciiChem
|
|
|
91
91
|
.select { |deficit| deficit >= 0 && deficit.modulo(1).zero? }
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
94
|
+
# π-electron-donating heteroatoms: a ring atom with no double bond
|
|
95
|
+
# of its own contributes its lone pair (pyrrole N, furan O,
|
|
96
|
+
# thiophene S) — the 2 electrons that make those five-membered
|
|
97
|
+
# rings 4n+2.
|
|
98
|
+
LONE_PAIR_DONORS = %w[N O S P Se As].freeze
|
|
99
|
+
|
|
100
|
+
# Aromatic rings by Hückel counting: 5- and 6-membered cycles whose
|
|
101
|
+
# π-electron contributions total 6 or 10 (4n+2 with n >= 1; the
|
|
102
|
+
# n = 0 case is a charged three-membered ring, which perception
|
|
103
|
+
# declines, and a 2π six-ring would otherwise misread a diene as
|
|
104
|
+
# aromatic). Per
|
|
105
|
+
# atom: an in-system double bond contributes 1; a lone-pair donor
|
|
106
|
+
# with no double contributes 2; a carbon with only single bonds or
|
|
107
|
+
# an exocyclic double contributes 0. Charged atoms make the count
|
|
108
|
+
# undecidable and the ring is declined; aromatic-flagged rings are
|
|
109
|
+
# taken as stated.
|
|
110
|
+
#
|
|
111
|
+
# This sees fused systems (naphthalene's shared bond is single, but
|
|
112
|
+
# each atom's double bond lies in the ring system) and keeps
|
|
113
|
+
# quinones out (their carbons' only double bonds point at
|
|
114
|
+
# exocyclic oxygens, so the ring counts 5, not 4n+2).
|
|
105
115
|
def aromatic_rings(atoms, edges)
|
|
106
116
|
cycles = ring_cycles(edges, max_size: 6, atom_count: atoms.length)
|
|
107
117
|
cyclic_atoms = cycles.flatten.uniq
|
|
108
|
-
|
|
109
|
-
six.select { |cycle| cycle.all? { |i| sp2?(i, atoms, edges, cyclic_atoms) } }
|
|
118
|
+
cycles.select { |cycle| aromatic_cycle?(cycle, atoms, edges, cyclic_atoms) }
|
|
110
119
|
end
|
|
111
120
|
|
|
112
|
-
def
|
|
113
|
-
return true if atoms[
|
|
121
|
+
def aromatic_cycle?(cycle, atoms, edges, cyclic_atoms)
|
|
122
|
+
return true if cycle.any? { |i| atoms[i].aromatic == true }
|
|
123
|
+
return false unless [5, 6].include?(cycle.length)
|
|
114
124
|
|
|
125
|
+
contributions = cycle.map do |i|
|
|
126
|
+
ring_pi_electrons(atoms[i], i, edges, cyclic_atoms)
|
|
127
|
+
end
|
|
128
|
+
return false if contributions.any?(&:nil?)
|
|
129
|
+
|
|
130
|
+
[6, 10].include?(contributions.sum)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# π electrons one ring atom contributes, or nil when the count is
|
|
134
|
+
# undecidable (charged atom).
|
|
135
|
+
def ring_pi_electrons(atom, atom_index, edges, cyclic_atoms)
|
|
136
|
+
return nil if atom.charge
|
|
137
|
+
return 1 if in_system_double?(atom_index, edges, cyclic_atoms)
|
|
138
|
+
return 2 if LONE_PAIR_DONORS.include?(atom.element)
|
|
139
|
+
|
|
140
|
+
0
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def in_system_double?(atom_index, edges, cyclic_atoms)
|
|
115
144
|
edges.any? do |edge|
|
|
116
145
|
next false unless edge.from == atom_index || edge.to == atom_index
|
|
117
146
|
next false unless edge.kind == :double
|
|
@@ -25,6 +25,10 @@ module AsciiChem
|
|
|
25
25
|
}.freeze
|
|
26
26
|
CONTINUATION_BONDS = %i[single aromatic].freeze
|
|
27
27
|
|
|
28
|
+
# Aromatic lowercase heteroatoms with an implicit hydrogen need
|
|
29
|
+
# brackets: bare `n`/`o`/`s` imply none.
|
|
30
|
+
HETERO_IMPLICIT_H = %w[N O S P Se As].freeze
|
|
31
|
+
|
|
28
32
|
def initialize(molecule)
|
|
29
33
|
@molecule = molecule
|
|
30
34
|
end
|
|
@@ -71,13 +75,27 @@ module AsciiChem
|
|
|
71
75
|
# item 2): atoms in perceived aromatic rings are written as
|
|
72
76
|
# aromatic (lowercase) and the ring bonds between them carry no
|
|
73
77
|
# bond symbol — Kekulé benzene canonicalizes to `c1ccccc1`, so
|
|
74
|
-
# both spellings converge. Perception's
|
|
75
|
-
#
|
|
78
|
+
# both spellings converge. Perception's Hückel counting keeps
|
|
79
|
+
# quinone-like rings out.
|
|
80
|
+
#
|
|
81
|
+
# Hydrogens are inferred for lowercase carbon but NOT for
|
|
82
|
+
# heteroatoms: pyrrole's N must be written `[nH]` or the SMILES
|
|
83
|
+
# states a pyridine-type nitrogen instead. The perceived
|
|
84
|
+
# implicit count for ring atoms is recorded for that decision.
|
|
76
85
|
def perceive_aromaticity(atoms, edges)
|
|
77
86
|
@perceived_aromatic = {}
|
|
78
87
|
@perceived_ring_edges = {}
|
|
88
|
+
@perceived_hydrogens = {}
|
|
79
89
|
AsciiChem::Perception.aromatic_rings(atoms, edges).each do |ring|
|
|
80
|
-
ring.each
|
|
90
|
+
ring.each do |index|
|
|
91
|
+
@perceived_aromatic[index] = true
|
|
92
|
+
next if atoms[index].aromatic == true
|
|
93
|
+
|
|
94
|
+
@perceived_hydrogens[index] = AsciiChem::Perception.implicit_hydrogens(
|
|
95
|
+
atoms[index],
|
|
96
|
+
AsciiChem::Perception.hydrogen_valence_sum(index, edges)
|
|
97
|
+
)
|
|
98
|
+
end
|
|
81
99
|
ring.each_with_index do |atom, i|
|
|
82
100
|
other = ring[(i + 1) % ring.length]
|
|
83
101
|
@perceived_ring_edges[[atom, other].minmax] = true
|
|
@@ -85,6 +103,19 @@ module AsciiChem
|
|
|
85
103
|
end
|
|
86
104
|
end
|
|
87
105
|
|
|
106
|
+
# Hydrogens the written token must state: the source's own count
|
|
107
|
+
# wins; otherwise only a ring PERCEIVED from Kekulé bond orders
|
|
108
|
+
# gets perception's implicit count. A source-aromatic atom
|
|
109
|
+
# already states its hydrogens the way SMILES does (`[nH]`), and
|
|
110
|
+
# aromatic 1.5 bond orders do not map onto heteroatom valences
|
|
111
|
+
# (thiophene's S would otherwise acquire a phantom H).
|
|
112
|
+
def writing_hydrogens(index)
|
|
113
|
+
stated = @atoms[index].hydrogens
|
|
114
|
+
return stated unless stated.nil?
|
|
115
|
+
|
|
116
|
+
@perceived_hydrogens[index]
|
|
117
|
+
end
|
|
118
|
+
|
|
88
119
|
# Aromatic for WRITING: the source flagged the atom, or
|
|
89
120
|
# perception found it in an aromatic ring.
|
|
90
121
|
def aromatic?(index)
|
|
@@ -269,8 +300,12 @@ module AsciiChem
|
|
|
269
300
|
atom = @atoms[index]
|
|
270
301
|
aromatic = aromatic?(index)
|
|
271
302
|
lowercase = aromatic && LOWERCASE_AROMATIC.include?(atom.element)
|
|
303
|
+
hyd = writing_hydrogens(index)
|
|
304
|
+
hetero_needs_h = aromatic && lowercase && hyd&.positive? &&
|
|
305
|
+
HETERO_IMPLICIT_H.include?(atom.element)
|
|
272
306
|
needs_bracket =
|
|
273
|
-
atom.charge || atom.isotope || atom.hydrogens || atom.chirality ||
|
|
307
|
+
atom.charge || atom.isotope || !atom.hydrogens.nil? || atom.chirality ||
|
|
308
|
+
hetero_needs_h ||
|
|
274
309
|
(!ORGANIC.include?(atom.element) && !lowercase) ||
|
|
275
310
|
(aromatic && !lowercase)
|
|
276
311
|
token =
|
|
@@ -280,10 +315,10 @@ module AsciiChem
|
|
|
280
315
|
t << atom.isotope if atom.isotope
|
|
281
316
|
t << symbol
|
|
282
317
|
t << chirality_token(index) if stereo && atom.chirality
|
|
283
|
-
if atom.hydrogens
|
|
318
|
+
if !atom.hydrogens.nil? || hetero_needs_h
|
|
284
319
|
t << 'H'
|
|
285
320
|
# Stated zero is H0, not a bare H (which means one).
|
|
286
|
-
t <<
|
|
321
|
+
t << hyd.to_s unless hyd == 1
|
|
287
322
|
end
|
|
288
323
|
t << charge_suffix(atom.charge) if atom.charge
|
|
289
324
|
t << ']'
|
data/lib/asciichem/version.rb
CHANGED