asciichem 0.22.0 → 0.24.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: d1aef5e42c0840b8bff4d291220e84f76f9536a9dba1cca3445deacb696388b1
4
- data.tar.gz: 92e28e623dfb7494080a9ee80d3545fa5a593d83d545775d890b7a9a5594c0bd
3
+ metadata.gz: d733aab290b1e54dfd0139d75a92888dfb8fc2af088544a07cc4395e523e583f
4
+ data.tar.gz: 5dfe21e949307366a6afbcc94bf2c52f9e9cfedb4a1f328996fbde3a20b5ec99
5
5
  SHA512:
6
- metadata.gz: bab5532c9007d1a5bd2079b792a66458d411f48af5ac59f04ad9e4a9150c7b53844639e67a46494dac7e6ae05f3451904a8fbe927b711803184749f00ce40e7e
7
- data.tar.gz: c0a9cb7f9f01e25dcce78f69894c23c8d5fe75d2d21fd20828d2daafa133186ebeb46d2aeb09bfc4246f43f851a3b982c4691cd013c40c56085634ca54f9db01
6
+ metadata.gz: cdeb663f26df5189cd73df0d7c5f6a9ad3918d981d924ec08f1a781728415af474840de566a8d8f1075cb0cdc0d7086f41f92b80250efee6f71ebd11cdc62680
7
+ data.tar.gz: 371e88363eb2e143b2b8a1b3f1a3cbc339d3eb81d5bcd521fdc00d02fd90e6068c769864185198c8e35e5ae6e2fc95b0088885d760844f527118a50469eaa9fb
data/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@
3
3
  All notable changes to AsciiChem are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.24.0] - 2026-09-13
7
+
8
+ ### Added
9
+ - `AsciiChem::Citation` (TODO.impl 44): one dataset-type Relaton
10
+ bibitem per (substance, source) — the citation track's core
11
+ contract. Source-specific fields live in per-source profiles
12
+ (PubChem, Common Chemistry); every identifier rides along as a
13
+ keyword so citations are machine-checkable. Runtime dependency:
14
+ relaton-bib (classic model).
15
+ - `Resolver::CommonChemistry` (TODO.impl 40, maintainer sign-off
16
+ 2026-09-13): CAS RN lookups against CAS Common Chemistry. CC BY-NC
17
+ 4.0 posture is enforced in code — the adapter never self-registers;
18
+ users opt in explicitly, and every result carries the required
19
+ attribution.
20
+
21
+ ## [0.23.0] - 2026-09-12
22
+
23
+ ### Added
24
+ - `AsciiChem::Resolver` — multi-source identifier resolution (TODO.impl
25
+ 38): opt-in adapter registry, one substance per source with
26
+ provenance, cross-check Conflict on InChIKey disagreement, TTL
27
+ cache in the user cache dir.
28
+ - `Resolver::PubChem` — keyless PUG-REST adapter with recorded
29
+ offline fixtures; CAS via the name namespace (TODO.impl 39).
30
+ - CLI `resolve` (networked, cached, `--refresh`) and offline
31
+ `validate` for identifier annotations (TODO.impl 41).
32
+
6
33
  ## [0.22.0] - 2026-09-12
7
34
 
8
35
  ### Added
@@ -399,7 +426,9 @@ This project follows [Semantic Versioning](https://semver.org/).
399
426
  `version`.
400
427
  - Comprehensive RSpec suite with round-trip conformance.
401
428
 
402
- [Unreleased]: https://github.com/asciichem/asciichem-ruby/compare/v0.22.0...HEAD
429
+ [Unreleased]: https://github.com/asciichem/asciichem-ruby/compare/v0.24.0...HEAD
430
+ [0.24.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.23.0...v0.24.0
431
+ [0.23.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.22.0...v0.23.0
403
432
  [0.22.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.21.0...v0.22.0
404
433
  [0.21.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.20.0...v0.21.0
405
434
  [0.20.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.19.0...v0.20.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,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
@@ -86,6 +86,61 @@ module AsciiChem
86
86
  "asciichem #{command.usage}"
87
87
  end
88
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
+
89
144
  private
90
145
 
91
146
  def read_source
@@ -0,0 +1,100 @@
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
+ # `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)
23
+ return @source_name if name.nil?
24
+
25
+ @source_name = name.to_s
26
+ AsciiChem::Resolver.register(@source_name, self) if register
27
+ end
28
+
29
+ def supports(*conventions)
30
+ @supported_conventions = conventions.map(&:to_s)
31
+ end
32
+
33
+ def supported_conventions
34
+ @supported_conventions || []
35
+ end
36
+ end
37
+
38
+ def supports?(convention)
39
+ self.class.supported_conventions.include?(convention.to_s)
40
+ end
41
+
42
+ # Cache-aware resolution. `fetch` must answer #get(url) -> body
43
+ # String (NetFetch by default; specs inject recorded responses).
44
+ def resolve(value:, convention:, fetch: nil, cache: nil, refresh: false)
45
+ cache ||= Cache.default
46
+ fetch ||= NetFetch.new
47
+ key = cache.key_for(self.class.source_name, convention, value)
48
+
49
+ unless refresh
50
+ cached = cache.read(key)
51
+ return cached if cached
52
+ end
53
+
54
+ substance = fetch_record(value: value, convention: convention, fetch: fetch)
55
+ .tap { |s| s&.identifiers&.each { |i| i.provenance ||= default_provenance } }
56
+ cache.write(key, substance) if substance
57
+ substance
58
+ end
59
+
60
+ # Not-found is nil, not an error — sources legitimately answer
61
+ # "unknown substance" during multi-source resolution.
62
+ def fetch_record(**)
63
+ raise NotImplementedError
64
+ end
65
+
66
+ def default_provenance
67
+ Provenance.new(
68
+ source: self.class.source_name,
69
+ retrieved_at: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
70
+ attribution: attribution
71
+ )
72
+ end
73
+
74
+ def attribution
75
+ nil
76
+ end
77
+ end
78
+
79
+ # Minimal stdlib HTTP transport. No runtime gem dependencies.
80
+ class NetFetch
81
+ def get(url, limit = 3)
82
+ raise Error, "too many redirects" if limit.zero?
83
+
84
+ uri = URI(url)
85
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
86
+ open_timeout: 5, read_timeout: 10) do |http|
87
+ http.request(Net::HTTP::Get.new(uri.request_uri, "User-Agent" => "asciichem-resolver"))
88
+ end
89
+
90
+ case response
91
+ when Net::HTTPSuccess then response.body
92
+ when Net::HTTPRedirection then get(URI.join(uri, response["location"]).to_s, limit - 1)
93
+ when Net::HTTPNotFound then nil
94
+ else
95
+ raise Error, "HTTP #{response.code}: #{response.message}"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ 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,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
@@ -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,79 @@
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 :CommonChemistry, "asciichem/resolver/common_chemistry"
21
+ autoload :Substance, "asciichem/resolver/substance"
22
+
23
+ # Raised when sources disagree on substance identity (cross-check
24
+ # mode). The message lists the disagreement — never hidden.
25
+ class Conflict < AsciiChem::Error; end
26
+
27
+ class << self
28
+ def adapters
29
+ @adapters ||= {}
30
+ end
31
+
32
+ def register(name, adapter)
33
+ adapters[name.to_s] = adapter
34
+ end
35
+
36
+ def [](name)
37
+ adapters.fetch(name.to_s)
38
+ rescue KeyError
39
+ raise Error, "unknown resolver source #{name.inspect} (registered: #{adapters.keys.sort.join(', ')})"
40
+ end
41
+
42
+ # Resolves against every opt-in source, returning one Substance
43
+ # per source that answers (nil entries dropped). Sources answer
44
+ # in registration order.
45
+ def resolve_all(value:, convention:, sources: nil, fetch: nil, cache: nil, refresh: false)
46
+ selected = sources ? Array(sources).map { |s| self[s] } : adapters.values
47
+ selected.filter_map do |adapter_class|
48
+ adapter = adapter_class.new
49
+ next unless adapter.supports?(convention)
50
+
51
+ adapter.resolve(value: value, convention: convention, fetch: fetch, cache: cache, refresh: refresh)
52
+ end
53
+ end
54
+
55
+ # First answering source wins.
56
+ def resolve(**args)
57
+ resolve_all(**args).first
58
+ end
59
+
60
+ # Cross-check mode: all sources must agree on the primary key
61
+ # (InChIKey) when more than one source provides one.
62
+ def resolve!(**args)
63
+ substances = resolve_all(**args)
64
+ keys = substances.filter_map { |s| s.identifier_value("inchikey") }.uniq
65
+ if keys.length > 1
66
+ raise Conflict,
67
+ "sources disagree on substance identity (InChIKeys: #{keys.join(' vs ')})"
68
+ end
69
+ substances.first
70
+ end
71
+ end
72
+
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
77
+ constants.each { |c| const_get(c) unless c == :Conflict }
78
+ end
79
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.22.0"
4
+ VERSION = "0.24.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"
@@ -25,6 +26,7 @@ module AsciiChem
25
26
  autoload :Molfile, "asciichem/molfile"
26
27
  autoload :Parser, "asciichem/parser"
27
28
  autoload :PeriodicTable, "asciichem/periodic_table"
29
+ autoload :Resolver, "asciichem/resolver"
28
30
  autoload :RingBonds, "asciichem/ring_bonds"
29
31
  autoload :Smiles, "asciichem/smiles"
30
32
  autoload :Structure, "asciichem/structure"
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.22.0
4
+ version: 0.24.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
@@ -156,6 +176,7 @@ files:
156
176
  - benchmarks/benchmark.rb
157
177
  - exe/asciichem
158
178
  - lib/asciichem.rb
179
+ - lib/asciichem/citation.rb
159
180
  - lib/asciichem/cli.rb
160
181
  - lib/asciichem/cml.rb
161
182
  - lib/asciichem/cml/conditions_extensions.rb
@@ -228,6 +249,12 @@ files:
228
249
  - lib/asciichem/molfile/writer.rb
229
250
  - lib/asciichem/parser.rb
230
251
  - lib/asciichem/periodic_table.rb
252
+ - lib/asciichem/resolver.rb
253
+ - lib/asciichem/resolver/adapter.rb
254
+ - lib/asciichem/resolver/cache.rb
255
+ - lib/asciichem/resolver/common_chemistry.rb
256
+ - lib/asciichem/resolver/pubchem.rb
257
+ - lib/asciichem/resolver/substance.rb
231
258
  - lib/asciichem/ring_bonds.rb
232
259
  - lib/asciichem/smiles.rb
233
260
  - lib/asciichem/smiles/parser.rb