asciichem 0.21.0 → 0.23.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: 2dee0024c277879fed3b5695a76e336067660467724663c4a67f71ec87215e80
4
- data.tar.gz: 50d5a53fd8d4f64a0d3969c586e81552faed9221a8f209932cd6823a2a966327
3
+ metadata.gz: a97d83231f84e4dde6c03c18f88ae7c34a30779e2550ca3f859e91bc58365f84
4
+ data.tar.gz: dbb27614ede6ba6987f09942465624b8d84dda4041444f63326982d84c365bdd
5
5
  SHA512:
6
- metadata.gz: 3afdd1db8e99729ca9a1a49be7a748fd56d1fdb666b5bfe10bb03a08c6d7db440ca82b3c900b6f22f09ac66901d7c086913a2a3e8c142bc6c7d776f21959c629
7
- data.tar.gz: ba3091db4ea3a3d7ba8dcd28f9689df323b135e1e1e4f0a3f3573f14350448461f9f3ade4e20fe4e7af9408d0ce40c1764bba4d609a05bd770705be431ca497b
6
+ metadata.gz: 47d0e9bf10643d6a7fb24779d1baaaf1382e4c11b1b43375e1543b7cacb5169ea94784db8c1ab70a2f117590966bc96ca4809c994fd83f6295ca63fa56bf3c01
7
+ data.tar.gz: 84386eb43091bbcde85bf7ed8bc428544865bd87f985e2b986d2578c5cb2e25a11d0ef39b880bcd781a5da6b061e4a6501d18a02bd2cf3016da369dd72afd365
data/CHANGELOG.md CHANGED
@@ -3,6 +3,31 @@
3
3
  All notable changes to AsciiChem are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.23.0] - 2026-09-12
7
+
8
+ ### Added
9
+ - `AsciiChem::Resolver` — multi-source identifier resolution (TODO.impl
10
+ 38): opt-in adapter registry, one substance per source with
11
+ provenance, cross-check Conflict on InChIKey disagreement, TTL
12
+ cache in the user cache dir.
13
+ - `Resolver::PubChem` — keyless PUG-REST adapter with recorded
14
+ offline fixtures; CAS via the name namespace (TODO.impl 39).
15
+ - CLI `resolve` (networked, cached, `--refresh`) and offline
16
+ `validate` for identifier annotations (TODO.impl 41).
17
+
18
+ ## [0.22.0] - 2026-09-12
19
+
20
+ ### Added
21
+ - CLI `--from asciichem|smiles|molfile` on `convert`, plus
22
+ `model-json`, `smiles`, `molfile`, and `structural-svg` output
23
+ targets (TODO.impl 60).
24
+
25
+ ### Changed
26
+ - Conformance validation uses schemas vendored in `spec/schemas`
27
+ (json_schemer dev dependency); the asciichem-model rubygem
28
+ dependency is removed — the contract repository is no longer
29
+ distributed as a gem (TODO.impl 59).
30
+
6
31
  ## [0.21.0] - 2026-09-12
7
32
 
8
33
  ### Added
@@ -386,7 +411,9 @@ This project follows [Semantic Versioning](https://semver.org/).
386
411
  `version`.
387
412
  - Comprehensive RSpec suite with round-trip conformance.
388
413
 
389
- [Unreleased]: https://github.com/asciichem/asciichem-ruby/compare/v0.21.0...HEAD
414
+ [Unreleased]: https://github.com/asciichem/asciichem-ruby/compare/v0.23.0...HEAD
415
+ [0.23.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.22.0...v0.23.0
416
+ [0.22.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.21.0...v0.22.0
390
417
  [0.21.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.20.0...v0.21.0
391
418
  [0.20.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.19.0...v0.20.0
392
419
  [0.18.1]: https://github.com/asciichem/asciichem-ruby/compare/v0.18.0...v0.18.1
data/Gemfile CHANGED
@@ -5,6 +5,7 @@ source "https://rubygems.org"
5
5
  gemspec
6
6
 
7
7
  group :development do
8
+ gem "json_schemer", "~> 2.4"
8
9
  gem "benchmark", "~> 0.4"
9
10
  gem "benchmark-ips", "~> 2.14", require: false
10
11
  gem "rake", "~> 13.2"
data/asciichem.gemspec CHANGED
@@ -41,5 +41,5 @@ Gem::Specification.new do |spec|
41
41
  spec.add_dependency "plurimath", "~> 0.8"
42
42
  spec.add_dependency "thor", "~> 1.3"
43
43
 
44
- spec.add_development_dependency "asciichem-model", "~> 0.3.4"
44
+ spec.add_development_dependency "json_schemer", "~> 2.4"
45
45
  end
data/lib/asciichem/cli.rb CHANGED
@@ -10,16 +10,22 @@ module AsciiChem
10
10
  # and command banners, matching the executable name.
11
11
  package_name "asciichem"
12
12
 
13
- desc "convert -i INPUT -t FORMAT", "Convert AsciiChem INPUT to FORMAT (mathml|text|html|latex|svg|cml)"
14
- method_option :input, aliases: "-i", type: :string, required: true,
15
- desc: "AsciiChem source text (or '-' for stdin)"
13
+ desc "convert -i INPUT -t FORMAT", "Convert INPUT to FORMAT (mathml|text|html|latex|svg|structural-svg|model-json|cml|smiles|molfile)"
14
+ method_option :input, aliases: "-i", type: :string,
15
+ desc: "Source text (or '-' for stdin)"
16
16
  method_option :file, aliases: "-f", type: :string,
17
- desc: "Read AsciiChem source from a file"
17
+ desc: "Read source from a file"
18
+ method_option :from, type: :string, default: "asciichem",
19
+ desc: "Input grammar: asciichem|smiles|molfile"
18
20
  method_option :format, aliases: "-t", type: :string, default: "mathml",
19
21
  desc: "Output format"
20
22
  def convert
23
+ unless options["input"] || options["file"]
24
+ raise AsciiChem::ParseError, "provide -i INPUT or -f FILE"
25
+ end
26
+
21
27
  source = read_source
22
- formula = AsciiChem.parse(source)
28
+ formula = ingest(source, options[:from])
23
29
  puts render(formula, options[:format])
24
30
  rescue AsciiChem::ParseError => e
25
31
  warn "Parse error: #{e.message}"
@@ -80,6 +86,61 @@ module AsciiChem
80
86
  "asciichem #{command.usage}"
81
87
  end
82
88
 
89
+ desc "resolve --cas X | --name X | ...", "Resolve a substance from a source (network; cached)"
90
+ method_option :cas, type: :string, desc: "CAS registry number"
91
+ method_option :name, type: :string, desc: "Substance name"
92
+ method_option :cid, type: :string, desc: "PubChem CID"
93
+ method_option :inchikey, type: :string, desc: "InChIKey"
94
+ method_option :smiles, type: :string, desc: "SMILES"
95
+ method_option :source, type: :string, default: "pubchem", desc: "Resolver source"
96
+ method_option :refresh, type: :boolean, default: false, desc: "Bypass the cache"
97
+ method_option :format, aliases: "-t", type: :string, default: "model-json",
98
+ desc: "Output: model-json | text | smiles"
99
+ def resolve
100
+ convention, value = %i[cas name cid inchikey smiles]
101
+ .filter_map { |k| [k, options[k.to_s]] if options[k.to_s] }
102
+ .first
103
+ raise AsciiChem::Error, "give one of --cas/--name/--cid/--inchikey/--smiles" unless value
104
+
105
+ convention = { cas: "cas", name: "name", cid: "pubchem-cid",
106
+ inchikey: "inchikey", smiles: "smiles" }.fetch(convention)
107
+ substance = AsciiChem::Resolver[options[:source]].new.resolve(
108
+ value: value, convention: convention, refresh: options[:refresh]
109
+ )
110
+ raise AsciiChem::Error, "#{options[:source]} does not know #{value.inspect}" unless substance
111
+
112
+ puts case options[:format].to_s
113
+ when "text" then substance.preferred_name.to_s
114
+ when "smiles" then substance.identifier_value("canonical-smiles").to_s
115
+ else substance.to_model_json
116
+ end
117
+ rescue AsciiChem::Error => e
118
+ warn "Resolve error: #{e.message}"
119
+ exit 3
120
+ end
121
+
122
+ desc "validate -i INPUT", "Offline identifier validation"
123
+ method_option :input, aliases: "-i", type: :string, required: true
124
+ def validate
125
+ formula = AsciiChem.parse(options[:input])
126
+ annotations = formula.nodes.grep(AsciiChem::Model::Molecule).flat_map(&:identifiers)
127
+ if annotations.empty?
128
+ puts "no identifier annotations found"
129
+ return
130
+ end
131
+ annotations.each do |identifier|
132
+ known = AsciiChem::Identifiers.known?(identifier.convention)
133
+ valid = known && AsciiChem::Identifiers.valid?(identifier.convention, identifier.value)
134
+ status = known ? (valid ? "ok" : "INVALID") : "unknown convention"
135
+ puts format("%-12s %-40s %s", identifier.convention, identifier.value, status)
136
+ end
137
+ exit 1 if annotations.any? { |i| AsciiChem::Identifiers.known?(i.convention) &&
138
+ !AsciiChem::Identifiers.valid?(i.convention, i.value) }
139
+ rescue AsciiChem::ParseError => e
140
+ warn "Parse error: #{e.message}"
141
+ exit 1
142
+ end
143
+
83
144
  private
84
145
 
85
146
  def read_source
@@ -89,8 +150,31 @@ module AsciiChem
89
150
  options[:input]
90
151
  end
91
152
 
153
+ # One ingestion point per input grammar (TODO.v2 09): every
154
+ # grammar funnels into the same semantic model, so every output
155
+ # format works regardless of the input language.
156
+ def ingest(source, from)
157
+ case from.to_s
158
+ when "asciichem" then AsciiChem.parse(source)
159
+ when "smiles" then AsciiChem.parse_smiles(source)
160
+ when "molfile" then molfile_formula(source)
161
+ else
162
+ raise AsciiChem::ParseError, "unknown --from grammar: #{from}"
163
+ end
164
+ end
165
+
166
+ # parse_molfile returns a single Molecule; wrap it so every
167
+ # formatter's Formula contract holds.
168
+ def molfile_formula(source)
169
+ text = File.file?(source) ? File.read(source) : source
170
+ AsciiChem::Model::Formula.new(nodes: [AsciiChem.parse_molfile(text)])
171
+ end
172
+
92
173
  def render(formula, format)
93
174
  return formula.to_cml if format.to_sym == :cml
175
+ return formula.to_model_json if format.to_sym == :"model-json"
176
+ return formula.to_smiles if format.to_sym == :smiles
177
+ return formula.nodes.first.to_molfile if format.to_sym == :molfile
94
178
 
95
179
  AsciiChem::Formatter.render(format.to_sym, formula)
96
180
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Resolver
5
+ # A source adapter. Subclasses register themselves:
6
+ #
7
+ # class MySource < Adapter
8
+ # source_name :mysource
9
+ # supports "name", "inchikey"
10
+ # def fetch_record(...); end
11
+ # end
12
+ #
13
+ # `resolve` handles caching around `fetch_record`; the transport
14
+ # (`fetch`) is injectable so specs run offline.
15
+ class Adapter
16
+ class << self
17
+ # Declares the source name and registers the adapter (called
18
+ # from the class body — `inherited` fires before the body
19
+ # runs, so it cannot see the subclass configuration).
20
+ def source_name(name = nil)
21
+ return @source_name if name.nil?
22
+
23
+ @source_name = name.to_s
24
+ AsciiChem::Resolver.register(@source_name, self)
25
+ end
26
+
27
+ def supports(*conventions)
28
+ @supported_conventions = conventions.map(&:to_s)
29
+ end
30
+
31
+ def supported_conventions
32
+ @supported_conventions || []
33
+ end
34
+ end
35
+
36
+ def supports?(convention)
37
+ self.class.supported_conventions.include?(convention.to_s)
38
+ end
39
+
40
+ # Cache-aware resolution. `fetch` must answer #get(url) -> body
41
+ # String (NetFetch by default; specs inject recorded responses).
42
+ def resolve(value:, convention:, fetch: nil, cache: nil, refresh: false)
43
+ cache ||= Cache.default
44
+ fetch ||= NetFetch.new
45
+ key = cache.key_for(self.class.source_name, convention, value)
46
+
47
+ unless refresh
48
+ cached = cache.read(key)
49
+ return cached if cached
50
+ end
51
+
52
+ substance = fetch_record(value: value, convention: convention, fetch: fetch)
53
+ .tap { |s| s&.identifiers&.each { |i| i.provenance ||= default_provenance } }
54
+ cache.write(key, substance) if substance
55
+ substance
56
+ end
57
+
58
+ # Not-found is nil, not an error — sources legitimately answer
59
+ # "unknown substance" during multi-source resolution.
60
+ def fetch_record(**)
61
+ raise NotImplementedError
62
+ end
63
+
64
+ def default_provenance
65
+ Provenance.new(
66
+ source: self.class.source_name,
67
+ retrieved_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
68
+ attribution: attribution
69
+ )
70
+ end
71
+
72
+ def attribution
73
+ nil
74
+ end
75
+ end
76
+
77
+ # Minimal stdlib HTTP transport. No runtime gem dependencies.
78
+ class NetFetch
79
+ def get(url, limit = 3)
80
+ raise Error, "too many redirects" if limit.zero?
81
+
82
+ uri = URI(url)
83
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
84
+ open_timeout: 5, read_timeout: 10) do |http|
85
+ http.request(Net::HTTP::Get.new(uri.request_uri, "User-Agent" => "asciichem-resolver"))
86
+ end
87
+
88
+ case response
89
+ when Net::HTTPSuccess then response.body
90
+ when Net::HTTPRedirection then get(URI.join(uri, response["location"]).to_s, limit - 1)
91
+ when Net::HTTPNotFound then nil
92
+ else
93
+ raise Error, "HTTP #{response.code}: #{response.message}"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "fileutils"
6
+
7
+ module AsciiChem
8
+ module Resolver
9
+ # Resolution cache in the USER cache dir (never the gem install
10
+ # dir): entries carry source, retrieved-at, attribution, TTL.
11
+ class Cache
12
+ DEFAULT_TTL = 7 * 24 * 60 * 60 # chemistry identifiers are stable
13
+
14
+ attr_reader :dir
15
+
16
+ def self.default_dir
17
+ base = ENV.fetch("XDG_CACHE_HOME", nil) || File.join(Dir.home, ".cache")
18
+ File.join(base, "asciichem", "resolver")
19
+ end
20
+
21
+ def initialize(dir: self.class.default_dir, ttl: DEFAULT_TTL)
22
+ @dir = dir
23
+ @ttl = ttl
24
+ end
25
+
26
+ def self.default
27
+ @default ||= new
28
+ end
29
+
30
+ def key_for(source, convention, value)
31
+ Digest::SHA256.hexdigest("#{source}:#{convention}:#{value}")
32
+ end
33
+
34
+ def read(key)
35
+ path = File.join(dir, "#{key}.json")
36
+ return nil unless File.file?(path)
37
+
38
+ entry = JSON.parse(File.read(path))
39
+ return nil if stale?(entry)
40
+
41
+ Substance.from_model_json(JSON.generate(entry["substance"]))
42
+ rescue JSON::ParserError, StandardError
43
+ nil
44
+ end
45
+
46
+ def write(key, substance)
47
+ FileUtils.mkdir_p(dir)
48
+ File.write(File.join(dir, "#{key}.json"), JSON.pretty_generate(
49
+ cached_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
50
+ ttl_seconds: @ttl,
51
+ substance: JSON.parse(substance.to_model_json)
52
+ ))
53
+ substance
54
+ end
55
+
56
+ private
57
+
58
+ def stale?(entry)
59
+ cached_at = Time.parse(entry["cached_at"]) rescue nil
60
+ return true unless cached_at
61
+
62
+ Time.now.utc - cached_at > entry.fetch("ttl_seconds", @ttl)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "net/https"
6
+ require "uri"
7
+
8
+ module AsciiChem
9
+ module Resolver
10
+ # PubChem source adapter (TODO.impl 39; TODO.v2 07 Layer 2).
11
+ # PubChem is the default source: keyless PUG-REST, permissive
12
+ # terms, and the bulk mirror of Common Chemistry's validated CAS
13
+ # RNs (CAS numbers are indexed as names). Politeness: one request
14
+ # per resolution, 5/10s timeouts, no retries — PubChem asks for
15
+ # max 5 requests/second.
16
+ #
17
+ # Citation profile (TODO.v2 08): publisher NCBI, per-access
18
+ # versioning (retrievedAt provenance), compound link
19
+ # https://pubchem.ncbi.nlm.nih.gov/compound/{cid}.
20
+ class PubChem < Adapter
21
+ source_name :pubchem
22
+ supports "name", "cas", "pubchem-cid", "inchikey", "inchi", "smiles", "canonical-smiles"
23
+
24
+ BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound"
25
+ PROPERTIES = %w[CanonicalSMILES IsomericSMILES MolecularFormula
26
+ MolecularWeight IUPACName InChI InChIKey].freeze
27
+ private_constant :BASE, :PROPERTIES
28
+
29
+ def fetch_record(value:, convention:, fetch:)
30
+ body = fetch.get(url_for(value, convention))
31
+ return nil unless body
32
+
33
+ props = JSON.parse(body).dig("PropertyTable", "Properties", 0)
34
+ return nil unless props
35
+
36
+ substance_from(props)
37
+ rescue JSON::ParserError => e
38
+ raise Error, "pubchem returned malformed JSON: #{e.message}"
39
+ end
40
+
41
+ def attribution
42
+ "PubChem, U.S. National Library of Medicine"
43
+ end
44
+
45
+ private
46
+
47
+ # CAS numbers are indexed as PubChem names (Common Chemistry
48
+ # mirror), so both map to the name namespace.
49
+ def url_for(value, convention)
50
+ namespace = { "name" => "name", "cas" => "name", "pubchem-cid" => "cid",
51
+ "inchikey" => "inchikey", "inchi" => "inchi",
52
+ "smiles" => "smiles", "canonical-smiles" => "smiles" }.fetch(convention.to_s)
53
+ "#{BASE}/#{namespace}/#{CGI.escape(value.to_s)}/property/#{PROPERTIES.join(',')}/JSON"
54
+ end
55
+
56
+ def substance_from(props)
57
+ provenance = default_provenance
58
+ identifiers = []
59
+ identifiers << Identifier.new(value: props["CID"].to_s, convention: "pubchem-cid", provenance: provenance) if props["CID"]
60
+ add_identifier(identifiers, props, "InChIKey", "inchikey", provenance)
61
+ add_identifier(identifiers, props, "InChI", "inchi", provenance)
62
+ add_identifier(identifiers, props, "CanonicalSMILES", "canonical-smiles", provenance)
63
+
64
+ Substance.new(
65
+ preferred_name: props["IUPACName"],
66
+ formula: props["MolecularFormula"],
67
+ molecular_weight: props["MolecularWeight"]&.to_f,
68
+ identifiers: identifiers,
69
+ properties: [
70
+ Property.new(name: "molecular-formula", value: props["MolecularFormula"], provenance: provenance),
71
+ Property.new(name: "molecular-weight", value: props["MolecularWeight"],
72
+ units: "g/mol", provenance: provenance)
73
+ ].compact,
74
+ # Best effort: PubChem canonical SMILES may use constructs
75
+ # outside the supported subset (stereo); then the SMILES
76
+ # stays as an identifier and structure is nil — no silent loss.
77
+ structure: parse_structure(props["CanonicalSMILES"])
78
+ )
79
+ end
80
+
81
+ def add_identifier(list, props, key, convention, provenance)
82
+ return unless props[key]
83
+
84
+ list << Identifier.new(value: props[key], convention: convention, provenance: provenance)
85
+ end
86
+
87
+ def parse_structure(smiles)
88
+ return nil unless smiles
89
+
90
+ AsciiChem.parse_smiles(smiles).nodes.first
91
+ rescue AsciiChem::ParseError
92
+ nil
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Resolver
5
+ # Domain value objects for resolution results. These are data
6
+ # carriers; (de)serialization to the canonical wire form goes
7
+ # through the lutaml-model Wire classes only.
8
+ Provenance = Struct.new(:source, :retrieved_at, :source_version, :attribution, keyword_init: true)
9
+ Identifier = Struct.new(:value, :convention, :provenance, keyword_init: true)
10
+ Property = Struct.new(:name, :value, :units, :provenance, keyword_init: true)
11
+
12
+ # A resolved substance: identity + provenance + best-effort
13
+ # structure. `structure` is a Model::Molecule when the source's
14
+ # SMILES parses within the supported subset; otherwise nil and the
15
+ # SMILES stays available as an identifier — nothing is dropped.
16
+ class Substance
17
+ attr_reader :preferred_name, :synonyms, :formula, :molecular_weight,
18
+ :identifiers, :properties, :structure
19
+
20
+ def initialize(preferred_name: nil, synonyms: [], formula: nil,
21
+ molecular_weight: nil, identifiers: [], properties: [],
22
+ structure: nil)
23
+ @preferred_name = preferred_name
24
+ @synonyms = synonyms
25
+ @formula = formula
26
+ @molecular_weight = molecular_weight
27
+ @identifiers = identifiers
28
+ @properties = properties
29
+ @structure = structure
30
+ end
31
+
32
+ def identifier_value(convention)
33
+ identifiers.find { |i| i.convention == convention }&.value
34
+ end
35
+
36
+ def provenance
37
+ identifiers.first&.provenance
38
+ end
39
+
40
+ # Canonical wire form (asciichem-model v1 substance-record).
41
+ def to_model_json
42
+ Wire::SubstanceRecord.new(
43
+ type: "substance-record",
44
+ preferred_name: preferred_name,
45
+ synonyms: synonyms,
46
+ formula: formula,
47
+ molecular_weight: molecular_weight,
48
+ identifiers: identifiers.map do |i|
49
+ Wire::ProvenancedIdentifier.new(
50
+ identifier: Wire::Identifier.new(type: "identifier", value: i.value, convention: i.convention),
51
+ provenance: provenance_wire(i.provenance)
52
+ )
53
+ end,
54
+ properties: properties.map do |p|
55
+ Wire::ProvenancedProperty.new(
56
+ name: p.name, value: p.value, units: p.units,
57
+ provenance: provenance_wire(p.provenance)
58
+ )
59
+ end
60
+ ).to_json
61
+ end
62
+
63
+ # Rebuilds a Substance from the wire form (the cache path).
64
+ def self.from_model_json(json)
65
+ wire = Wire::SubstanceRecord.from_json(json)
66
+ new(
67
+ preferred_name: wire.preferred_name,
68
+ synonyms: Array(wire.synonyms),
69
+ formula: wire.formula,
70
+ molecular_weight: wire.molecular_weight,
71
+ identifiers: Array(wire.identifiers).map do |pi|
72
+ Identifier.new(
73
+ value: pi.identifier&.value,
74
+ convention: pi.identifier&.convention,
75
+ provenance: provenance_domain(pi.provenance)
76
+ )
77
+ end,
78
+ properties: Array(wire.properties).map do |p|
79
+ Property.new(name: p.name, value: p.value, units: p.units,
80
+ provenance: provenance_domain(p.provenance))
81
+ end
82
+ )
83
+ end
84
+
85
+ def self.provenance_domain(wire)
86
+ return nil unless wire
87
+
88
+ Provenance.new(source: wire.source, retrieved_at: wire.retrieved_at,
89
+ source_version: wire.source_version, attribution: wire.attribution)
90
+ end
91
+
92
+ private
93
+
94
+ def provenance_wire(p)
95
+ return nil unless p
96
+
97
+ Wire::Provenance.new(
98
+ type: "provenance",
99
+ source: p.source, retrieved_at: p.retrieved_at,
100
+ source_version: p.source_version, attribution: p.attribution
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "asciichem/errors"
4
+
5
+ module AsciiChem
6
+ # Resolution turns identifiers into substance records (TODO.v2 07
7
+ # Layer 2; TODO.impl 38). Multi-source by design: every answering
8
+ # source produces its own record with provenance, because the
9
+ # citation track (TODO.v2 08) emits one bibitem per (substance,
10
+ # source) the user chooses to cite.
11
+ #
12
+ # Sources are opt-in adapters that self-register (OCP, same pattern
13
+ # as the linter checks). NC-licensed sources are never registered by
14
+ # default — Common Chemistry ships only after the maintainer
15
+ # sign-off recorded in TODO.v2 07.
16
+ module Resolver
17
+ autoload :Adapter, "asciichem/resolver/adapter"
18
+ autoload :Cache, "asciichem/resolver/cache"
19
+ autoload :PubChem, "asciichem/resolver/pubchem"
20
+ autoload :Substance, "asciichem/resolver/substance"
21
+
22
+ # Raised when sources disagree on substance identity (cross-check
23
+ # mode). The message lists the disagreement — never hidden.
24
+ class Conflict < AsciiChem::Error; end
25
+
26
+ class << self
27
+ def adapters
28
+ @adapters ||= {}
29
+ end
30
+
31
+ def register(name, adapter)
32
+ adapters[name.to_s] = adapter
33
+ end
34
+
35
+ def [](name)
36
+ adapters.fetch(name.to_s)
37
+ rescue KeyError
38
+ raise Error, "unknown resolver source #{name.inspect} (registered: #{adapters.keys.sort.join(', ')})"
39
+ end
40
+
41
+ # Resolves against every opt-in source, returning one Substance
42
+ # per source that answers (nil entries dropped). Sources answer
43
+ # in registration order.
44
+ def resolve_all(value:, convention:, sources: nil, fetch: nil, cache: nil, refresh: false)
45
+ selected = sources ? Array(sources).map { |s| self[s] } : adapters.values
46
+ selected.filter_map do |adapter_class|
47
+ adapter = adapter_class.new
48
+ next unless adapter.supports?(convention)
49
+
50
+ adapter.resolve(value: value, convention: convention, fetch: fetch, cache: cache, refresh: refresh)
51
+ end
52
+ end
53
+
54
+ # First answering source wins.
55
+ def resolve(**args)
56
+ resolve_all(**args).first
57
+ end
58
+
59
+ # Cross-check mode: all sources must agree on the primary key
60
+ # (InChIKey) when more than one source provides one.
61
+ def resolve!(**args)
62
+ substances = resolve_all(**args)
63
+ keys = substances.filter_map { |s| s.identifier_value("inchikey") }.uniq
64
+ if keys.length > 1
65
+ raise Conflict,
66
+ "sources disagree on substance identity (InChIKeys: #{keys.join(' vs ')})"
67
+ end
68
+ substances.first
69
+ end
70
+ end
71
+
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
75
+ constants.each { |c| const_get(c) unless c == :Conflict }
76
+ end
77
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.21.0"
4
+ VERSION = "0.23.0"
5
5
  end
data/lib/asciichem.rb CHANGED
@@ -25,6 +25,7 @@ module AsciiChem
25
25
  autoload :Molfile, "asciichem/molfile"
26
26
  autoload :Parser, "asciichem/parser"
27
27
  autoload :PeriodicTable, "asciichem/periodic_table"
28
+ autoload :Resolver, "asciichem/resolver"
28
29
  autoload :RingBonds, "asciichem/ring_bonds"
29
30
  autoload :Smiles, "asciichem/smiles"
30
31
  autoload :Structure, "asciichem/structure"
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env bash
2
+ # Re-vendor the asciichem-model v1 JSON Schemas (YAML) and examples.
3
+ # Usage: scripts/update-model-schemas.sh [model-tag] (default: main).
4
+ set -euo pipefail
5
+
6
+ tag="${1:-main}"
7
+ work="$(mktemp -d)"
8
+ trap 'rm -rf "$work"' EXIT
9
+
10
+ git clone --depth 1 --branch "$tag" https://github.com/asciichem/asciichem-model.git "$work/model"
11
+
12
+ python3 - "$work/model" <<'PY'
13
+ import os, shutil, sys
14
+ src = os.path.join(sys.argv[1], "schemas", "v1")
15
+ dst = "spec/schemas"
16
+ if os.path.exists(dst):
17
+ shutil.rmtree(dst)
18
+ os.makedirs(dst)
19
+ for name in os.listdir(src):
20
+ if name.endswith(".yaml"):
21
+ shutil.copy(os.path.join(src, name), os.path.join(dst, name))
22
+ shutil.copytree(os.path.join(src, "examples"), os.path.join(dst, "examples"))
23
+ print("vendored", len([f for f in os.listdir(dst) if f.endswith('.yaml')]), "schemas")
24
+ PY
25
+
26
+ echo "spec/schemas updated from asciichem-model $tag"
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.21.0
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -114,19 +114,19 @@ dependencies:
114
114
  - !ruby/object:Gem::Version
115
115
  version: '1.3'
116
116
  - !ruby/object:Gem::Dependency
117
- name: asciichem-model
117
+ name: json_schemer
118
118
  requirement: !ruby/object:Gem::Requirement
119
119
  requirements:
120
120
  - - "~>"
121
121
  - !ruby/object:Gem::Version
122
- version: 0.3.4
122
+ version: '2.4'
123
123
  type: :development
124
124
  prerelease: false
125
125
  version_requirements: !ruby/object:Gem::Requirement
126
126
  requirements:
127
127
  - - "~>"
128
128
  - !ruby/object:Gem::Version
129
- version: 0.3.4
129
+ version: '2.4'
130
130
  description: AsciiChem is an ASCII syntax for representing chemical formulae, reactions,
131
131
  electron configurations, bonds, crystals, spectra, calculations, Z-matrices, and
132
132
  reaction mechanisms. It parses to a semantic model and renders to MathML, HTML,
@@ -228,6 +228,11 @@ files:
228
228
  - lib/asciichem/molfile/writer.rb
229
229
  - lib/asciichem/parser.rb
230
230
  - lib/asciichem/periodic_table.rb
231
+ - lib/asciichem/resolver.rb
232
+ - lib/asciichem/resolver/adapter.rb
233
+ - lib/asciichem/resolver/cache.rb
234
+ - lib/asciichem/resolver/pubchem.rb
235
+ - lib/asciichem/resolver/substance.rb
231
236
  - lib/asciichem/ring_bonds.rb
232
237
  - lib/asciichem/smiles.rb
233
238
  - lib/asciichem/smiles/parser.rb
@@ -245,6 +250,7 @@ files:
245
250
  - lib/asciichem/wire/identity.rb
246
251
  - lib/asciichem/wire_adapter.rb
247
252
  - lib/asciichem/xml_builder.rb
253
+ - scripts/update-model-schemas.sh
248
254
  homepage: https://www.asciichem.org
249
255
  licenses:
250
256
  - BSD-2-Clause