asciichem 0.23.0 → 0.25.0

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: a97d83231f84e4dde6c03c18f88ae7c34a30779e2550ca3f859e91bc58365f84
4
- data.tar.gz: dbb27614ede6ba6987f09942465624b8d84dda4041444f63326982d84c365bdd
3
+ metadata.gz: 7748aa7fb152b3dad47ff42c2debe7335af35a2a36c21d2248ff4f5a5231c575
4
+ data.tar.gz: 4352b4fe40fc6998f9860d155e791d7cf2c4f3cd48f0184696346ba934adaa57
5
5
  SHA512:
6
- metadata.gz: 47d0e9bf10643d6a7fb24779d1baaaf1382e4c11b1b43375e1543b7cacb5169ea94784db8c1ab70a2f117590966bc96ca4809c994fd83f6295ca63fa56bf3c01
7
- data.tar.gz: 84386eb43091bbcde85bf7ed8bc428544865bd87f985e2b986d2578c5cb2e25a11d0ef39b880bcd781a5da6b061e4a6501d18a02bd2cf3016da369dd72afd365
6
+ metadata.gz: 6a22a8e911ec6a04498285f949f79c1ea5e7e6870d464e3229c3e783e744d151f0506b5200dba58e4e06f23177983dd80b24d172c6d5528e960907da821db64a
7
+ data.tar.gz: 9d57bab57705518c6f2b40d28f40b0b62c20237d26b9daf20ae4d65bd9b60ad8497ea304c60c163878c412cc6cf61a3c99a6a9179b027bef527e524758b87085
data/CHANGELOG.md CHANGED
@@ -3,6 +3,30 @@
3
3
  All notable changes to AsciiChem are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.25.0] - 2026-09-14
7
+
8
+ ### Added
9
+ - CLI `cite` — resolve a substance and emit the dataset-type Relaton
10
+ bibitem XML (the citation track's user-facing entry point).
11
+ - Cross-engine parsing benchmarks (`benchmarks/engines.rb`,
12
+ README with Ruby/TS/Python numbers) and the parsanol investigation
13
+ verdict (compat shim ~6x slower than parslet; not adopted).
14
+
15
+ ## [0.24.0] - 2026-09-13
16
+
17
+ ### Added
18
+ - `AsciiChem::Citation` (TODO.impl 44): one dataset-type Relaton
19
+ bibitem per (substance, source) — the citation track's core
20
+ contract. Source-specific fields live in per-source profiles
21
+ (PubChem, Common Chemistry); every identifier rides along as a
22
+ keyword so citations are machine-checkable. Runtime dependency:
23
+ relaton-bib (classic model).
24
+ - `Resolver::CommonChemistry` (TODO.impl 40, maintainer sign-off
25
+ 2026-09-13): CAS RN lookups against CAS Common Chemistry. CC BY-NC
26
+ 4.0 posture is enforced in code — the adapter never self-registers;
27
+ users opt in explicitly, and every result carries the required
28
+ attribution.
29
+
6
30
  ## [0.23.0] - 2026-09-12
7
31
 
8
32
  ### Added
@@ -411,7 +435,9 @@ This project follows [Semantic Versioning](https://semver.org/).
411
435
  `version`.
412
436
  - Comprehensive RSpec suite with round-trip conformance.
413
437
 
414
- [Unreleased]: https://github.com/asciichem/asciichem-ruby/compare/v0.23.0...HEAD
438
+ [Unreleased]: https://github.com/asciichem/asciichem-ruby/compare/v0.25.0...HEAD
439
+ [0.25.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.24.0...v0.25.0
440
+ [0.24.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.23.0...v0.24.0
415
441
  [0.23.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.22.0...v0.23.0
416
442
  [0.22.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.21.0...v0.22.0
417
443
  [0.21.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.20.0...v0.21.0
data/asciichem.gemspec CHANGED
@@ -38,6 +38,7 @@ Gem::Specification.new do |spec|
38
38
  spec.add_dependency "lutaml-model", ">= 0.8", "< 2"
39
39
  spec.add_dependency "nokogiri", "~> 1.16"
40
40
  spec.add_dependency "parslet", "~> 2.0"
41
+ spec.add_dependency "relaton-bib", ">= 0.1", "< 2"
41
42
  spec.add_dependency "plurimath", "~> 0.8"
42
43
  spec.add_dependency "thor", "~> 1.3"
43
44
 
@@ -0,0 +1,46 @@
1
+ # Parsing benchmarks
2
+
3
+ Shared workload (identical inputs in every implementation) so numbers
4
+ are comparable across engines:
5
+
6
+ ```ruby
7
+ WORKLOAD = ["H_2O", "Ca^2+", "SO_4^2-", "(R)-CH_3CH(OH)COOH",
8
+ "2H_2 + O_2 -> 2H_2O", "N_2 + 3H_2 <=>[Fe][400C] 2NH_3",
9
+ "C1-C-C-C-C-C1", "CH_3-CH_2-OH",
10
+ "^14C @name(\"carbon-14\") @cas(\"14104-86-4\")",
11
+ "A ->[heat] B ->[cool] C"]
12
+ ```
13
+
14
+ | Engine | Batch (10 inputs) | Per input | Notes |
15
+ |---|---|---|---|
16
+ | Ruby (parslet), 3.4.8 arm64 | 29.1 ms | ~2.9 ms | `bundle exec ruby benchmarks/engines.rb` |
17
+ | Ruby + parse+text | 35.3 ms | ~3.5 ms | round-trip adds the formatter |
18
+ | TypeScript (peggy), Node 24 | 0.19 ms | ~19 µs | `npm run bench` (asciichem-ts) |
19
+ | Python (RD), 3.10 | 3.94 ms | ~394 µs | `python benchmarks/engines.py` (asciichem-py) |
20
+
21
+ The peggy engine is ~15x faster than parslet and ~20x faster than the
22
+ Python recursive-descent parser on this workload — the grammar-port
23
+ TS implementation did not trade away speed. Same machine (arm64),
24
+ single-run medians; treat as order-of-magnitude comparison.
25
+
26
+ ## Parsanol investigation (2026-09-14)
27
+
28
+ [Parsanol](https://github.com/parsanol/parsanol-ruby) (Ribose's
29
+ parslet-alternative PEG library with a Rust native core) was evaluated
30
+ as a drop-in speedup for the reference grammar:
31
+
32
+ 1. **Parslet-compat shim** (zero code change — re-parent the grammar
33
+ onto `Parsanol::Parslet::Parser`): the identical grammar runs
34
+ unchanged (10/10 workload inputs), but measures **~6x slower**
35
+ than parslet (12.2 s vs 2.05 s per 300x10 parses, Ruby 3.4.8,
36
+ arm64). The shim is a compatibility layer, not the fast path.
37
+ 2. **Native Parsanol DSL** (subset micro-benchmark): constructs run,
38
+ but boundary semantics differ from parslet on greedy-regex +
39
+ `maybe`/`repeat` interaction (`SO_4^2-` parses under parslet,
40
+ fails under Parsanol native). A full port would need per-rule
41
+ revalidation against the whole corpus with no measured win to
42
+ justify it yet.
43
+
44
+ **Verdict: not adopted.** Revisit when Parsanol's native backend can
45
+ be engaged for full grammars and shows measured wins on this
46
+ workload; the shim path is a documented no.
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Cross-engine parsing benchmark (shared inputs across Ruby/TS/Python
4
+ # so numbers are comparable). Measures full parse (+ Text round-trip
5
+ # where cheap) over the canonical workload, reporting ops/sec and
6
+ # µs/op. Run: bundle exec ruby benchmarks/engines.rb
7
+ require "benchmark/ips"
8
+ require "asciichem"
9
+
10
+ WORKLOAD = [
11
+ "H_2O",
12
+ "Ca^2+",
13
+ "SO_4^2-",
14
+ "(R)-CH_3CH(OH)COOH",
15
+ "2H_2 + O_2 -> 2H_2O",
16
+ "N_2 + 3H_2 <=>[Fe][400C] 2NH_3",
17
+ "C1-C-C-C-C-C1",
18
+ "CH_3-CH_2-OH",
19
+ "^14C @name(\"carbon-14\") @cas(\"14104-86-4\")",
20
+ "A ->[heat] B ->[cool] C",
21
+ ].freeze
22
+
23
+ Benchmark.ips do |x|
24
+ x.report("parse x10 (parslet)") do
25
+ WORKLOAD.each { |s| AsciiChem.parse(s) }
26
+ end
27
+ x.report("parse+text x10") do
28
+ WORKLOAD.each { |s| AsciiChem.parse(s).to_text }
29
+ end
30
+ x.compare!
31
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "relaton_bib"
4
+
5
+ module AsciiChem
6
+ # Citation track (TODO.v2 08; TODO.impl 44): a bibitem is a function
7
+ # of (substance, source) — one dataset-type Relaton bibitem per
8
+ # source the user chooses to cite, because two databases agreeing on
9
+ # a substance are still two different documents.
10
+ #
11
+ # Source-specific fields (link shape, publisher string, docidentifier
12
+ # scheme) live in PROFILES; adding a source is one entry (OCP).
13
+ module Citation
14
+ # Source-specific citation profile. Default fields fall back to
15
+ # the substance's provenance.
16
+ Profile = Struct.new(:publisher, :link_for, :identifier_for, keyword_init: true)
17
+
18
+ PROFILES = {
19
+ "pubchem" => Profile.new(
20
+ publisher: "PubChem, U.S. National Library of Medicine",
21
+ link_for: ->(substance) do
22
+ cid = substance.identifier_value("pubchem-cid")
23
+ "https://pubchem.ncbi.nlm.nih.gov/compound/#{cid}" if cid
24
+ end,
25
+ identifier_for: ->(substance) do
26
+ cid = substance.identifier_value("pubchem-cid")
27
+ "PubChem CID #{cid}" if cid
28
+ end
29
+ ),
30
+ "common_chemistry" => Profile.new(
31
+ publisher: "CAS Common Chemistry",
32
+ link_for: ->(substance) do
33
+ cas = substance.identifier_value("cas")
34
+ "https://commonchemistry.cas.org/detail?cas_rn=#{cas}" if cas
35
+ end,
36
+ identifier_for: ->(substance) do
37
+ cas = substance.identifier_value("cas")
38
+ "CAS RN #{cas}" if cas
39
+ end
40
+ )
41
+ }.freeze
42
+
43
+ DEFAULT_PROFILE = Profile.new(
44
+ publisher: nil,
45
+ link_for: ->(_substance) { nil },
46
+ identifier_for: ->(substance) do
47
+ key = substance.identifiers.first
48
+ "#{key.convention}: #{key.value}" if key
49
+ end
50
+ ).freeze
51
+ private_constant :DEFAULT_PROFILE
52
+
53
+ class << self
54
+ # Builds a Relaton dataset-type bibitem for the substance as
55
+ # cited from its recorded source. Raises when the substance
56
+ # carries no provenance (hand-built, not resolved).
57
+ def bibitem(substance)
58
+ provenance = substance.provenance
59
+ unless provenance&.source
60
+ raise Error, "substance has no provenance - resolve it first (AsciiChem::Resolver)"
61
+ end
62
+
63
+ profile = PROFILES.fetch(provenance.source, DEFAULT_PROFILE)
64
+ RelatonBib::BibliographicItem.new(
65
+ type: "dataset",
66
+ title: [{ type: "main",
67
+ content: "#{title_base(substance)} - #{profile.publisher || provenance.source} substance record" }],
68
+ docid: [RelatonBib::DocumentIdentifier.new(
69
+ id: profile.identifier_for.call(substance) || "#{provenance.source} substance",
70
+ type: provenance.source)],
71
+ contributor: [{ entity: RelatonBib::Organization.new(name: profile.publisher || provenance.source),
72
+ role: [{ type: "publisher" }] }],
73
+ date: [{ type: "accessed", on: accessed_on(provenance) }],
74
+ link: [{ type: "src", content: profile.link_for.call(substance) }].compact,
75
+ keyword: substance.identifiers.map { |i| "#{i.convention}=#{i.value}" }
76
+ )
77
+ end
78
+
79
+ # Convenience: bibitem XML (what a document pipeline embeds).
80
+ def to_xml(substance)
81
+ bibitem(substance).to_xml
82
+ end
83
+
84
+ private
85
+
86
+ def title_base(substance)
87
+ substance.preferred_name || substance.identifier_value("cas") ||
88
+ substance.identifier_value("inchikey") || "Substance"
89
+ end
90
+
91
+ def accessed_on(provenance)
92
+ return provenance.retrieved_at[0, 10] if provenance.retrieved_at
93
+
94
+ Time.now.utc.strftime("%Y-%m-%d")
95
+ end
96
+ end
97
+ end
98
+ end
data/lib/asciichem/cli.rb CHANGED
@@ -119,6 +119,33 @@ module AsciiChem
119
119
  exit 3
120
120
  end
121
121
 
122
+ desc "cite --cas X | --name X | ...", "Resolve a substance and emit a dataset-type Relaton bibitem (XML)"
123
+ method_option :cas, type: :string, desc: "CAS registry number"
124
+ method_option :name, type: :string, desc: "Substance name"
125
+ method_option :cid, type: :string, desc: "PubChem CID"
126
+ method_option :inchikey, type: :string, desc: "InChIKey"
127
+ method_option :smiles, type: :string, desc: "SMILES"
128
+ method_option :source, type: :string, default: "pubchem", desc: "Resolver source"
129
+ method_option :refresh, type: :boolean, default: false, desc: "Bypass the cache"
130
+ def cite
131
+ convention, value = %i[cas name cid inchikey smiles]
132
+ .filter_map { |k| [k, options[k.to_s]] if options[k.to_s] }
133
+ .first
134
+ raise AsciiChem::Error, "give one of --cas/--name/--cid/--inchikey/--smiles" unless value
135
+
136
+ convention = { cas: "cas", name: "name", cid: "pubchem-cid",
137
+ inchikey: "inchikey", smiles: "smiles" }.fetch(convention)
138
+ substance = AsciiChem::Resolver[options[:source]].new.resolve(
139
+ value: value, convention: convention, refresh: options[:refresh]
140
+ )
141
+ raise AsciiChem::Error, "#{options[:source]} does not know #{value.inspect}" unless substance
142
+
143
+ puts AsciiChem::Citation.to_xml(substance)
144
+ rescue AsciiChem::Error => e
145
+ warn "Cite error: #{e.message}"
146
+ exit 4
147
+ end
148
+
122
149
  desc "validate -i INPUT", "Offline identifier validation"
123
150
  method_option :input, aliases: "-i", type: :string, required: true
124
151
  def validate
@@ -17,11 +17,13 @@ module AsciiChem
17
17
  # Declares the source name and registers the adapter (called
18
18
  # from the class body — `inherited` fires before the body
19
19
  # runs, so it cannot see the subclass configuration).
20
- def source_name(name = nil)
20
+ # `register: false` for opt-in sources (NC-licensed): the
21
+ # adapter exists but users must register it explicitly.
22
+ def source_name(name = nil, register: true)
21
23
  return @source_name if name.nil?
22
24
 
23
25
  @source_name = name.to_s
24
- AsciiChem::Resolver.register(@source_name, self)
26
+ AsciiChem::Resolver.register(@source_name, self) if register
25
27
  end
26
28
 
27
29
  def supports(*conventions)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+
6
+ module AsciiChem
7
+ module Resolver
8
+ # CAS Common Chemistry source adapter (TODO.impl 40; TODO.v2 07).
9
+ #
10
+ # License posture (maintainer sign-off 2026-09-13): Common
11
+ # Chemistry data is CC BY-NC 4.0 — this adapter is OPT-IN and never
12
+ # self-registers; users register it explicitly:
13
+ #
14
+ # AsciiChem::Resolver.register(:common_chemistry,
15
+ # AsciiChem::Resolver::CommonChemistry)
16
+ #
17
+ # Every result carries the required attribution.
18
+ class CommonChemistry < Adapter
19
+ source_name :common_chemistry, register: false
20
+ supports "cas"
21
+
22
+ BASE = "https://commonchemistry.cas.org/api/detail"
23
+
24
+ def fetch_record(value:, convention:, fetch:)
25
+ body = fetch.get("#{BASE}?cas_rn=#{CGI.escape(value.to_s)}")
26
+ return nil unless body
27
+
28
+ data = JSON.parse(body)
29
+ return nil if data.empty?
30
+
31
+ substance_from(data)
32
+ rescue JSON::ParserError => e
33
+ raise Error, "common_chemistry returned malformed JSON: #{e.message}"
34
+ end
35
+
36
+ def attribution
37
+ "CAS Common Chemistry (CC BY-NC 4.0)"
38
+ end
39
+
40
+ private
41
+
42
+ def substance_from(data)
43
+ provenance = default_provenance
44
+ identifiers = [Identifier.new(value: data["rn"], convention: "cas",
45
+ provenance: provenance)]
46
+ add_identifier(identifiers, data, "inchikey", "inchikey", provenance)
47
+ add_identifier(identifiers, data, "smiles", "canonical-smiles", provenance)
48
+ add_identifier(identifiers, data, "inchi", "inchi", provenance)
49
+ add_identifier(identifiers, data, "iupacName", "iupac-name", provenance)
50
+
51
+ Substance.new(
52
+ preferred_name: data["name"],
53
+ formula: data["formula"],
54
+ identifiers: identifiers,
55
+ properties: [
56
+ Property.new(name: "molecular-formula", value: data["formula"], provenance: provenance),
57
+ Property.new(name: "molecular-weight", value: data["molecularMass"]&.to_s,
58
+ units: "g/mol", provenance: provenance)
59
+ ].compact,
60
+ # Best effort within the supported SMILES subset; the SMILES
61
+ # stays as an identifier when stereo keeps it out.
62
+ structure: parse_structure(data["smiles"])
63
+ )
64
+ end
65
+
66
+ def add_identifier(list, data, key, convention, provenance)
67
+ return unless data[key]
68
+
69
+ list << Identifier.new(value: data[key], convention: convention,
70
+ provenance: provenance)
71
+ end
72
+
73
+ def parse_structure(smiles)
74
+ return nil unless smiles
75
+
76
+ AsciiChem.parse_smiles(smiles).nodes.first
77
+ rescue AsciiChem::ParseError
78
+ nil
79
+ end
80
+ end
81
+ end
82
+ end
@@ -17,6 +17,7 @@ module AsciiChem
17
17
  autoload :Adapter, "asciichem/resolver/adapter"
18
18
  autoload :Cache, "asciichem/resolver/cache"
19
19
  autoload :PubChem, "asciichem/resolver/pubchem"
20
+ autoload :CommonChemistry, "asciichem/resolver/common_chemistry"
20
21
  autoload :Substance, "asciichem/resolver/substance"
21
22
 
22
23
  # Raised when sources disagree on substance identity (cross-check
@@ -69,9 +70,10 @@ module AsciiChem
69
70
  end
70
71
  end
71
72
 
72
- # Open, permissive sources register eagerly; opt-in NC sources do
73
- # not self-register (registered manually after sign-off).
74
- adapters # ensure the registry exists before adapter files load
73
+ # Open, permissive sources register eagerly. CommonChemistry is
74
+ # CC BY-NC 4.0: it loads (autoload) but never self-registers —
75
+ # users opt in explicitly via Resolver.register.
76
+ adapters
75
77
  constants.each { |c| const_get(c) unless c == :Conflict }
76
78
  end
77
79
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.23.0"
4
+ VERSION = "0.25.0"
5
5
  end
data/lib/asciichem.rb CHANGED
@@ -10,6 +10,7 @@ require "plurimath"
10
10
  # AsciiChem::Cli.start # CLI dispatch
11
11
  module AsciiChem
12
12
  autoload :Cli, "asciichem/cli"
13
+ autoload :Citation, "asciichem/citation"
13
14
  autoload :Cml, "asciichem/cml"
14
15
  autoload :Error, "asciichem/errors"
15
16
  autoload :ParseError, "asciichem/errors"
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.23.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -85,6 +85,26 @@ dependencies:
85
85
  - - "~>"
86
86
  - !ruby/object:Gem::Version
87
87
  version: '2.0'
88
+ - !ruby/object:Gem::Dependency
89
+ name: relaton-bib
90
+ requirement: !ruby/object:Gem::Requirement
91
+ requirements:
92
+ - - ">="
93
+ - !ruby/object:Gem::Version
94
+ version: '0.1'
95
+ - - "<"
96
+ - !ruby/object:Gem::Version
97
+ version: '2'
98
+ type: :runtime
99
+ prerelease: false
100
+ version_requirements: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0.1'
105
+ - - "<"
106
+ - !ruby/object:Gem::Version
107
+ version: '2'
88
108
  - !ruby/object:Gem::Dependency
89
109
  name: plurimath
90
110
  requirement: !ruby/object:Gem::Requirement
@@ -152,10 +172,13 @@ files:
152
172
  - RELEASING.md
153
173
  - Rakefile
154
174
  - asciichem.gemspec
175
+ - benchmarks/README.md
155
176
  - benchmarks/RESULTS.md
156
177
  - benchmarks/benchmark.rb
178
+ - benchmarks/engines.rb
157
179
  - exe/asciichem
158
180
  - lib/asciichem.rb
181
+ - lib/asciichem/citation.rb
159
182
  - lib/asciichem/cli.rb
160
183
  - lib/asciichem/cml.rb
161
184
  - lib/asciichem/cml/conditions_extensions.rb
@@ -231,6 +254,7 @@ files:
231
254
  - lib/asciichem/resolver.rb
232
255
  - lib/asciichem/resolver/adapter.rb
233
256
  - lib/asciichem/resolver/cache.rb
257
+ - lib/asciichem/resolver/common_chemistry.rb
234
258
  - lib/asciichem/resolver/pubchem.rb
235
259
  - lib/asciichem/resolver/substance.rb
236
260
  - lib/asciichem/ring_bonds.rb