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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9c3f297f95612987dbe2a4e7f15bcaa0d4628dbae0d074dce5aeea2fb5e0efa
4
- data.tar.gz: 75a1ef4ea31451fccfb37369299d86afa1a08b26c825054de2eda109c636a561
3
+ metadata.gz: 6c9a131b0a052014c60f5be8b74972de24db882f7db088b82bdea1c506709a46
4
+ data.tar.gz: 9b280851bdc1fb8c4f4681f1887796f2eb688298f8c495cd81a766a879fd8a09
5
5
  SHA512:
6
- metadata.gz: cba5c440d3da6c3a436b207d36203f07641ad1b76c61ade69ac5e39697c1b79a81374f7e7106a5764611b3f400f28f9dae89a7054a70c4124b6c21da0d6f8c8a
7
- data.tar.gz: e7cb123f63497d962297ea6629e684a0b8af6417c494a7332e02a9729585e977a0c02e2acdd6ee7eb99868fa70475b9a8e57e748eb38fde789957c66a8dc2afa
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
@@ -91,27 +91,56 @@ module AsciiChem
91
91
  .select { |deficit| deficit >= 0 && deficit.modulo(1).zero? }
92
92
  end
93
93
 
94
- # Six-membered cycles where every atom is sp2 — participates in a
95
- # double bond to an atom that is itself in a ring, or is
96
- # aromatic-flagged. The partner-in-a-ring requirement keeps
97
- # quinones out: their carbons are sp2, but the double bonds that
98
- # make them so point at exocyclic oxygens, and writing such a
99
- # ring as aromatic SMILES would state the wrong bond orders. A
100
- # fused system still qualifies — naphthalene's fusion carbons
101
- # double-bond to atoms of the neighbouring ring. Per-ring strict
102
- # alternation can never see both rings (the shared bond is
103
- # single); per-atom sp2-in-the-system does. Full Hückel
104
- # pi-electron counting is a later slice.
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
- six = cycles.select { |cycle| cycle.length == 6 }
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 sp2?(atom_index, atoms, edges, cyclic_atoms)
113
- return true if atoms[atom_index].aromatic == true
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 in-ring double-bond
75
- # rule keeps quinone-like rings out.
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 { |index| @perceived_aromatic[index] = true }
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 << atom.hydrogens.to_s unless atom.hydrogens == 1
321
+ t << hyd.to_s unless hyd == 1
287
322
  end
288
323
  t << charge_suffix(atom.charge) if atom.charge
289
324
  t << ']'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.31.4"
4
+ VERSION = "0.31.5"
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.4
4
+ version: 0.31.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.