asciichem 0.19.0 → 0.20.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: 49f3af36729edc3293bb97099d74298533a509ae26aa3c14151a7cef743f654f
4
- data.tar.gz: 24f5d0778e9f7dce477423b27b1ec5ebcab08472452389f126473234e8500a19
3
+ metadata.gz: 6a77da515ac52e848a4ff0f5855a595cb81d71a36c18b4991d875d682d2c3598
4
+ data.tar.gz: 70c14be9ddec1e767ddddef09a1ad91a02f996f1fdd22645d0269c4de7e96020
5
5
  SHA512:
6
- metadata.gz: 599beaeb33d3bf776076632c83d2c92c16a08571088f3bb272163f61a9082e250af150b3300a80821fc3bb6ab5ffa8d65cc8b5007b4860f0dace0a94b70aaf83
7
- data.tar.gz: 6fbc557626b9767110bc142c54ae6858b644d563af73f431bd48d8dbd7dabdfeeb79b7ecbb498474d791b35f1717b9ae28689ee1630f352fa48a3e0abcf966a6
6
+ metadata.gz: b71e29edd758d5d4393d750a95676dc8007c5850f830b414ca83a2d21e99c1ff39fa88f5f66b54198e442e7d29b9010639c4a55138567cd9dee8160d5b2be3dc
7
+ data.tar.gz: f08f835457ce6d58b70ff0b3bc502f84f4404684a13ae60ad52bbae44a790dfd66621c15bba73ed2843e0d76ba4ef47545a37e838867b54d12eb9246f11277b6
@@ -14,8 +14,17 @@ jobs:
14
14
  ruby: ["3.3", "3.4"]
15
15
  steps:
16
16
  - uses: actions/checkout@v4
17
+ - name: Clone conformance corpus (asciichem-tests)
18
+ run: git clone --depth 1 https://github.com/asciichem/asciichem-tests.git ../asciichem-tests
17
19
  - uses: ruby/setup-ruby@v1
18
20
  with:
19
21
  ruby-version: ${{ matrix.ruby }}
20
22
  bundler-cache: true
21
23
  - run: bundle exec rspec
24
+ - name: Publish conformance report
25
+ if: matrix.ruby == '3.4'
26
+ uses: actions/upload-artifact@v4
27
+ with:
28
+ name: conformance
29
+ path: conformance.json
30
+ if-no-files-found: warn
@@ -11,14 +11,17 @@ on:
11
11
  jobs:
12
12
  release:
13
13
  runs-on: ubuntu-latest
14
- environment: release
14
+ # Trusted publishing (OIDC): the publisher registered on RubyGems.org
15
+ # is repository asciichem/asciichem-ruby + workflow release.yml, with
16
+ # no environment — so this job must not claim one.
15
17
  permissions:
16
18
  contents: read
17
- packages: write
19
+ id-token: write
18
20
  steps:
19
21
  - uses: actions/checkout@v4
20
22
  with:
21
23
  ref: main
24
+ persist-credentials: false
22
25
  - name: Verify version matches input
23
26
  run: |
24
27
  actual=$(ruby -e 'require "./lib/asciichem/version"; print AsciiChem::VERSION')
@@ -31,21 +34,8 @@ jobs:
31
34
  with:
32
35
  ruby-version: "3.4"
33
36
  bundler-cache: true
34
- - name: Build gem
35
- run: bundle exec rake build
36
- - name: Set up RubyGems credentials
37
- env:
38
- RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
39
- run: |
40
- mkdir -p $HOME/.gem
41
- cat > $HOME/.gem/credentials <<EOF
42
- ---
43
- :rubygems_api_key: ${RUBYGEMS_API_KEY}
44
- EOF
45
- chmod 0600 $HOME/.gem/credentials
46
- - name: Push to RubyGems
47
- run: |
48
- gem push pkg/asciichem-${{ inputs.version }}.gem
37
+ # Builds and pushes using the GitHub OIDC identity — no API keys.
38
+ - uses: rubygems/release-gem@v1
49
39
  - name: Summary
50
40
  run: |
51
41
  echo "Released asciichem ${{ inputs.version }} to RubyGems"
data/.gitignore CHANGED
@@ -12,3 +12,4 @@
12
12
  /.vscode/
13
13
  /.idea/
14
14
  *.gem
15
+ conformance.json
data/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to AsciiChem are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.20.0] - 2026-09-12
7
+
8
+ ### Added
9
+ - Canonical JSON wire form (`to_model_json` / `AsciiChem.from_model_json`)
10
+ per asciichem-model v1: `AsciiChem::Wire` (lutaml-model Serializable
11
+ classes, json mappings only - no hand-rolled serialization) bridged by
12
+ `AsciiChem::WireAdapter` (model-to-model conversion, same pattern as
13
+ the CML ModelAdapter). Emission covers every node type; ingestion
14
+ covers the lossless core set (beyond-formulas nodes are emission-only
15
+ until their corpus round-trip acceptance lands).
16
+ - Conformance runner over the shared corpus (asciichem-tests): L0
17
+ emission + schema validation, L1 Text round-trip, L3 CML round-trip,
18
+ L4 linter diagnostics, plus the ParseError contract for rejects.
19
+ Emits conformance.json; CI clones the corpus and publishes the report
20
+ as an artifact. Current claim: L0 149/149, L1 16/16, L3 23/23, L4 6/6.
21
+
6
22
  ## [0.19.0] - 2026-09-09
7
23
 
8
24
  ### Added
@@ -347,7 +363,8 @@ This project follows [Semantic Versioning](https://semver.org/).
347
363
  `version`.
348
364
  - Comprehensive RSpec suite with round-trip conformance.
349
365
 
350
- [Unreleased]: https://github.com/asciichem/asciichem-ruby/commits/main
366
+ [Unreleased]: https://github.com/asciichem/asciichem-ruby/compare/v0.20.0...HEAD
367
+ [0.20.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.19.0...v0.20.0
351
368
  [0.18.1]: https://github.com/asciichem/asciichem-ruby/compare/v0.18.0...v0.18.1
352
369
  [0.18.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.17.0...v0.18.0
353
370
  [0.17.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.16.0...v0.17.0
data/RELEASING.md CHANGED
@@ -83,10 +83,15 @@ tag.
83
83
 
84
84
  == Publish to RubyGems
85
85
 
86
- [source,sh]
87
- ----
88
- gem push pkg/asciichem-0.X.Y.gem
89
- ----
86
+ The Release workflow (`.github/workflows/release.yml`) publishes via
87
+ https://guides.rubygems.org/trusted-publishing/[RubyGems trusted publishing]
88
+ (OIDC) — there are no API keys. Its trusted publisher is registered on
89
+ https://rubygems.org (repository `asciichem/asciichem-ruby`, workflow
90
+ `release.yml`, no environment).
91
+
92
+ On the repository's Actions tab, run the "Release" workflow with the
93
+ version number. It verifies the version matches `version.rb`, builds
94
+ the gem, and pushes it using the workflow's GitHub OIDC identity.
90
95
 
91
96
  Verify at https://rubygems.org/gems/asciichem.
92
97
 
data/asciichem.gemspec CHANGED
@@ -35,8 +35,11 @@ Gem::Specification.new do |spec|
35
35
 
36
36
  spec.add_dependency "chemicalml", "~> 0.3.0"
37
37
  spec.add_dependency "elkrb", "~> 1.0"
38
+ spec.add_dependency "lutaml-model", ">= 0.8", "< 2"
38
39
  spec.add_dependency "nokogiri", "~> 1.16"
39
40
  spec.add_dependency "parslet", "~> 2.0"
40
41
  spec.add_dependency "plurimath", "~> 0.8"
41
42
  spec.add_dependency "thor", "~> 1.3"
43
+
44
+ spec.add_development_dependency "asciichem-model", "~> 0.3.4"
42
45
  end
@@ -62,6 +62,14 @@ module AsciiChem
62
62
  AsciiChem::Cml.from_asciichem(self)
63
63
  end
64
64
 
65
+ # Canonical JSON wire form (asciichem-model v1). The Text
66
+ # formatter canonicalises AsciiChem text; this canonicalises
67
+ # the semantic model itself — the interchange format every
68
+ # implementation must parse and emit.
69
+ def to_model_json
70
+ AsciiChem::WireAdapter.to_model_json(self)
71
+ end
72
+
65
73
  # Subclasses override to expose the attributes that participate in
66
74
  # equality. Default: empty (so two bare Nodes are equal).
67
75
  def value_attributes
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.19.0"
4
+ VERSION = "0.20.0"
5
5
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Wire
5
+ # Common base for wire nodes: declares the `type` discriminator
6
+ # attribute and registers the class under its wire type string.
7
+ # The converter sets `type` explicitly on construction because
8
+ # lutaml-model omits default values on serialisation.
9
+ class Base < Lutaml::Model::Serializable
10
+ class << self
11
+ def wire_type(name = nil)
12
+ return @wire_type if name.nil?
13
+
14
+ @wire_type = name.to_s
15
+ AsciiChem::Wire::REGISTRY[name.to_s] = self
16
+ end
17
+ end
18
+
19
+ attribute :type, :string
20
+ json do
21
+ map "type", to: :type
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Wire
5
+ # Chemistry document wire classes: formula (root), reactions,
6
+ # electron configurations, embedded math, text.
7
+ class Formula < Base
8
+ wire_type "formula"
9
+
10
+ attribute :nodes, Base, collection: true
11
+ json do
12
+ map "nodes", to: :nodes
13
+ end
14
+ end
15
+
16
+ class ReactionConditions < Lutaml::Model::Serializable
17
+ attribute :above, :string
18
+ attribute :below, :string
19
+ json do
20
+ map "above", to: :above
21
+ map "below", to: :below
22
+ end
23
+ end
24
+
25
+ class Reaction < Base
26
+ wire_type "reaction"
27
+
28
+ attribute :reactants, "AsciiChem::Wire::Molecule", collection: true
29
+ attribute :products, "AsciiChem::Wire::Molecule", collection: true
30
+ attribute :arrow, :string
31
+ attribute :conditions, ReactionConditions
32
+ json do
33
+ map "reactants", to: :reactants
34
+ map "products", to: :products
35
+ map "arrow", to: :arrow
36
+ map "conditions", to: :conditions
37
+ end
38
+ end
39
+
40
+ class ReactionCascade < Base
41
+ wire_type "reaction-cascade"
42
+
43
+ attribute :steps, "AsciiChem::Wire::Reaction", collection: true
44
+ json do
45
+ map "steps", to: :steps
46
+ end
47
+ end
48
+
49
+ class OrbitalOccupancy < Lutaml::Model::Serializable
50
+ attribute :orbital, :string
51
+ attribute :occupancy, :string
52
+ json do
53
+ map "orbital", to: :orbital
54
+ map "occupancy", to: :occupancy
55
+ end
56
+ end
57
+
58
+ class TermSymbol < Lutaml::Model::Serializable
59
+ attribute :multiplicity, :string
60
+ attribute :letter, :string
61
+ attribute :j_value, :string
62
+ json do
63
+ map "multiplicity", to: :multiplicity
64
+ map "letter", to: :letter
65
+ map "jValue", to: :j_value
66
+ end
67
+ end
68
+
69
+ class ElectronConfiguration < Base
70
+ wire_type "electron-configuration"
71
+
72
+ attribute :orbitals, OrbitalOccupancy, collection: true
73
+ attribute :term_symbol, TermSymbol
74
+ json do
75
+ map "orbitals", to: :orbitals
76
+ map "termSymbol", to: :term_symbol
77
+ end
78
+ end
79
+
80
+ class EmbeddedMath < Base
81
+ wire_type "embedded-math"
82
+
83
+ attribute :source, :string
84
+ json do
85
+ map "source", to: :source
86
+ end
87
+ end
88
+
89
+ class Text < Base
90
+ wire_type "text"
91
+
92
+ attribute :content, :string
93
+ json do
94
+ map "content", to: :content
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Wire
5
+ # Core node wire classes: atom, identifier, name, molecule,
6
+ # group, bond. Field-for-field with schemas/v1. Order matters:
7
+ # referenced classes are declared before their referrers.
8
+ class Atom < Base
9
+ wire_type "atom"
10
+
11
+ attribute :element, :string
12
+ attribute :isotope, :string
13
+ attribute :charge, :string
14
+ attribute :subscript, :string
15
+ attribute :oxidation_state, :string
16
+ attribute :lone_pairs, :integer
17
+ attribute :radical_electrons, :integer
18
+ attribute :ring_closures, :string
19
+ json do
20
+ map "element", to: :element
21
+ map "isotope", to: :isotope
22
+ map "charge", to: :charge
23
+ map "subscript", to: :subscript
24
+ map "oxidationState", to: :oxidation_state
25
+ map "lonePairs", to: :lone_pairs
26
+ map "radicalElectrons", to: :radical_electrons
27
+ map "ringClosures", to: :ring_closures
28
+ end
29
+ end
30
+
31
+ class Identifier < Base
32
+ wire_type "identifier"
33
+
34
+ attribute :value, :string
35
+ attribute :convention, :string
36
+ attribute :dict_ref, :string
37
+ json do
38
+ map "value", to: :value
39
+ map "convention", to: :convention
40
+ map "dictRef", to: :dict_ref
41
+ end
42
+ end
43
+
44
+ class Name < Base
45
+ wire_type "name"
46
+
47
+ attribute :content, :string
48
+ attribute :convention, :string
49
+ attribute :dict_ref, :string
50
+ json do
51
+ map "content", to: :content
52
+ map "convention", to: :convention
53
+ map "dictRef", to: :dict_ref
54
+ end
55
+ end
56
+
57
+ class Molecule < Base
58
+ wire_type "molecule"
59
+
60
+ attribute :nodes, Base, collection: true
61
+ attribute :coefficient, :string
62
+ attribute :identifiers, Identifier, collection: true
63
+ json do
64
+ map "nodes", to: :nodes
65
+ map "coefficient", to: :coefficient
66
+ map "identifiers", to: :identifiers
67
+ end
68
+ end
69
+
70
+ class Group < Base
71
+ wire_type "group"
72
+
73
+ attribute :nodes, Base, collection: true
74
+ attribute :multiplicity, :string
75
+ attribute :bracket, :string
76
+ json do
77
+ map "nodes", to: :nodes
78
+ map "multiplicity", to: :multiplicity
79
+ map "bracket", to: :bracket
80
+ end
81
+ end
82
+
83
+ class Bond < Base
84
+ wire_type "bond"
85
+
86
+ attribute :kind, :string
87
+ json do
88
+ map "kind", to: :kind
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Wire
5
+ # Beyond-formulas wire classes: mechanism, spectrum, crystal,
6
+ # zmatrix, calculation. Emission covers the full v1 schemas;
7
+ # ingestion (from_wire) is currently implemented for the core
8
+ # chemistry set only — see WireAdapter.
9
+ class MechanismStep < Lutaml::Model::Serializable
10
+ attribute :label, :string
11
+ attribute :reaction, "AsciiChem::Wire::Reaction"
12
+ json do
13
+ map "label", to: :label
14
+ map "reaction", to: :reaction
15
+ end
16
+ end
17
+
18
+ class Mechanism < Base
19
+ wire_type "mechanism"
20
+
21
+ attribute :steps, MechanismStep, collection: true
22
+ attribute :spectators, "AsciiChem::Wire::Molecule", collection: true
23
+ json do
24
+ map "steps", to: :steps
25
+ map "spectators", to: :spectators
26
+ end
27
+ end
28
+
29
+ class SpectrumPeak < Lutaml::Model::Serializable
30
+ attribute :position, :string
31
+ attribute :intensity, :string
32
+ attribute :multiplicity, :string
33
+ attribute :assignment, :string
34
+ json do
35
+ map "position", to: :position
36
+ map "intensity", to: :intensity
37
+ map "multiplicity", to: :multiplicity
38
+ map "assignment", to: :assignment
39
+ end
40
+ end
41
+
42
+ class Spectrum < Base
43
+ wire_type "spectrum"
44
+
45
+ attribute :technique, :string
46
+ attribute :params, Lutaml::Model::Type::Hash
47
+ attribute :peaks, SpectrumPeak, collection: true
48
+ json do
49
+ map "technique", to: :technique
50
+ map "params", to: :params
51
+ map "peaks", to: :peaks
52
+ end
53
+ end
54
+
55
+ class Crystal < Base
56
+ wire_type "crystal"
57
+
58
+ attribute :name, :string
59
+ attribute :a, :float
60
+ attribute :b, :float
61
+ attribute :c, :float
62
+ attribute :alpha, :float
63
+ attribute :beta, :float
64
+ attribute :gamma, :float
65
+ attribute :spacegroup, :string
66
+ attribute :atoms, Atom, collection: true
67
+ json do
68
+ map "name", to: :name
69
+ map "a", to: :a
70
+ map "b", to: :b
71
+ map "c", to: :c
72
+ map "alpha", to: :alpha
73
+ map "beta", to: :beta
74
+ map "gamma", to: :gamma
75
+ map "spacegroup", to: :spacegroup
76
+ map "atoms", to: :atoms
77
+ end
78
+ end
79
+
80
+ class ZRow < Lutaml::Model::Serializable
81
+ attribute :atom, :string
82
+ attribute :ref1, :string
83
+ attribute :distance, :string
84
+ attribute :ref2, :string
85
+ attribute :angle, :string
86
+ attribute :ref3, :string
87
+ attribute :dihedral, :string
88
+ json do
89
+ map "atom", to: :atom
90
+ map "ref1", to: :ref1
91
+ map "distance", to: :distance
92
+ map "ref2", to: :ref2
93
+ map "angle", to: :angle
94
+ map "ref3", to: :ref3
95
+ map "dihedral", to: :dihedral
96
+ end
97
+ end
98
+
99
+ class ZMatrix < Base
100
+ wire_type "zmatrix"
101
+
102
+ attribute :rows, ZRow, collection: true
103
+ json do
104
+ map "rows", to: :rows
105
+ end
106
+ end
107
+
108
+ class CalculatedProperty < Lutaml::Model::Serializable
109
+ attribute :title, :string
110
+ attribute :value, :string
111
+ attribute :units, :string
112
+ attribute :dict_ref, :string
113
+ attribute :convention, :string
114
+ json do
115
+ map "title", to: :title
116
+ map "value", to: :value
117
+ map "units", to: :units
118
+ map "dictRef", to: :dict_ref
119
+ map "convention", to: :convention
120
+ end
121
+ end
122
+
123
+ class Calculation < Base
124
+ wire_type "calculation"
125
+
126
+ attribute :method, :string
127
+ attribute :basis, :string
128
+ attribute :properties, CalculatedProperty, collection: true
129
+ json do
130
+ map "method", to: :method
131
+ map "basis", to: :basis
132
+ map "properties", to: :properties
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Wire
5
+ # Identity wire classes: provenance and the resolved substance
6
+ # record produced by resolver sources (TODO.impl 38+).
7
+ class Provenance < Base
8
+ wire_type "provenance"
9
+
10
+ attribute :source, :string
11
+ attribute :retrieved_at, :string
12
+ attribute :source_version, :string
13
+ attribute :attribution, :string
14
+ json do
15
+ map "source", to: :source
16
+ map "retrievedAt", to: :retrieved_at
17
+ map "sourceVersion", to: :source_version
18
+ map "attribution", to: :attribution
19
+ end
20
+ end
21
+
22
+ class ProvenancedIdentifier < Lutaml::Model::Serializable
23
+ attribute :identifier, Identifier
24
+ attribute :provenance, Provenance
25
+ json do
26
+ map "identifier", to: :identifier
27
+ map "provenance", to: :provenance
28
+ end
29
+ end
30
+
31
+ class ProvenancedProperty < Lutaml::Model::Serializable
32
+ attribute :name, :string
33
+ attribute :value, :string
34
+ attribute :units, :string
35
+ attribute :provenance, Provenance
36
+ json do
37
+ map "name", to: :name
38
+ map "value", to: :value
39
+ map "units", to: :units
40
+ map "provenance", to: :provenance
41
+ end
42
+ end
43
+
44
+ class SubstanceRecord < Base
45
+ wire_type "substance-record"
46
+
47
+ attribute :preferred_name, :string
48
+ attribute :synonyms, :string, collection: true
49
+ attribute :formula, :string
50
+ attribute :molecular_weight, :float
51
+ attribute :identifiers, ProvenancedIdentifier, collection: true
52
+ attribute :properties, ProvenancedProperty, collection: true
53
+ json do
54
+ map "preferredName", to: :preferred_name
55
+ map "synonyms", to: :synonyms
56
+ map "formula", to: :formula
57
+ map "molecularWeight", to: :molecular_weight
58
+ map "identifiers", to: :identifiers
59
+ map "properties", to: :properties
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module AsciiChem
6
+ # Canonical wire form of the semantic model (the JSON interchange
7
+ # format defined by asciichem-model v1). Wire classes are
8
+ # lutaml-model Serializables: every (de)serialization goes through
9
+ # the framework's `json do map ... end` declarations — never
10
+ # hand-rolled key manipulation. AsciiChem::WireAdapter converts
11
+ # between the domain Model::* tree and this wire tree, exactly as
12
+ # ModelAdapter bridges AsciiChem::Model and Chemicalml::Model.
13
+ module Wire
14
+ # Discriminator ("atom", "molecule", ...) => wire class. Filled
15
+ # by Base.wire_type at class-definition time.
16
+ REGISTRY = {}
17
+
18
+ autoload :Base, "asciichem/wire/base"
19
+ autoload :Atom, "asciichem/wire/core"
20
+ autoload :Molecule, "asciichem/wire/core"
21
+ autoload :Group, "asciichem/wire/core"
22
+ autoload :Bond, "asciichem/wire/core"
23
+ autoload :Identifier, "asciichem/wire/core"
24
+ autoload :Name, "asciichem/wire/core"
25
+ autoload :Formula, "asciichem/wire/chemistry"
26
+ autoload :Reaction, "asciichem/wire/chemistry"
27
+ autoload :ReactionConditions, "asciichem/wire/chemistry"
28
+ autoload :ReactionCascade, "asciichem/wire/chemistry"
29
+ autoload :ElectronConfiguration, "asciichem/wire/chemistry"
30
+ autoload :OrbitalOccupancy, "asciichem/wire/chemistry"
31
+ autoload :TermSymbol, "asciichem/wire/chemistry"
32
+ autoload :EmbeddedMath, "asciichem/wire/chemistry"
33
+ autoload :Text, "asciichem/wire/chemistry"
34
+ autoload :Mechanism, "asciichem/wire/extended"
35
+ autoload :MechanismStep, "asciichem/wire/extended"
36
+ autoload :Spectrum, "asciichem/wire/extended"
37
+ autoload :SpectrumPeak, "asciichem/wire/extended"
38
+ autoload :Crystal, "asciichem/wire/extended"
39
+ autoload :ZMatrix, "asciichem/wire/extended"
40
+ autoload :ZRow, "asciichem/wire/extended"
41
+ autoload :Calculation, "asciichem/wire/extended"
42
+ autoload :CalculatedProperty, "asciichem/wire/extended"
43
+ autoload :Provenance, "asciichem/wire/identity"
44
+ autoload :SubstanceRecord, "asciichem/wire/identity"
45
+ autoload :ProvenancedIdentifier, "asciichem/wire/identity"
46
+ autoload :ProvenancedProperty, "asciichem/wire/identity"
47
+
48
+ # Eagerly trigger every autoload so wire classes register their
49
+ # discriminators before any lookup (same pattern as Linter).
50
+ constants.each { |name| const_get(name) unless name == :REGISTRY }
51
+ end
52
+ end
@@ -0,0 +1,361 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Converts between the domain semantic model (AsciiChem::Model::*)
5
+ # and the canonical wire form (AsciiChem::Wire::*). This is a
6
+ # model-to-model translation in the spirit of ModelAdapter — every
7
+ # (de)serialisation to/from JSON itself is performed by lutaml-model
8
+ # on the Wire classes; this adapter only routes types and maps
9
+ # fields.
10
+ #
11
+ # Emission (to_wire/to_model_json) covers every wire type.
12
+ # Ingestion (from_wire/from_model_json) currently covers the
13
+ # lossless core set: formula, atom, molecule, group, bond,
14
+ # identifier, name, reaction (+conditions), reaction-cascade,
15
+ # electron-configuration, embedded-math, text. The beyond-formulas
16
+ # nodes (mechanism, spectrum, crystal, zmatrix, calculation) are
17
+ # emission-only until their round-trip acceptance lands in the
18
+ # corpus.
19
+ module WireAdapter
20
+ CORE_INGEST_TYPES = %w[
21
+ formula atom molecule group bond identifier name reaction
22
+ reaction-cascade electron-configuration embedded-math text
23
+ ].freeze
24
+
25
+ class << self
26
+ def to_model_json(formula)
27
+ to_wire(formula).to_json
28
+ end
29
+
30
+ def from_model_json(json)
31
+ from_wire(wire_tree_from_hash(JSON.parse(json)))
32
+ end
33
+
34
+ # domain -> wire ------------------------------------------------
35
+
36
+ def to_wire(node)
37
+ case node
38
+ when AsciiChem::Model::Formula then formula_to_wire(node)
39
+ when AsciiChem::Model::Molecule then molecule_to_wire(node)
40
+ when AsciiChem::Model::Group then group_to_wire(node)
41
+ when AsciiChem::Model::Atom then atom_to_wire(node)
42
+ when AsciiChem::Model::Bond then bond_to_wire(node)
43
+ when AsciiChem::Model::Identifier then identifier_to_wire(node)
44
+ when AsciiChem::Model::Name then name_to_wire(node)
45
+ when AsciiChem::Model::Reaction then reaction_to_wire(node)
46
+ when AsciiChem::Model::ReactionCascade then cascade_to_wire(node)
47
+ when AsciiChem::Model::ElectronConfiguration then econfig_to_wire(node)
48
+ when AsciiChem::Model::EmbeddedMath then math_to_wire(node)
49
+ when AsciiChem::Model::Text then text_to_wire(node)
50
+ when AsciiChem::Model::Mechanism then mechanism_to_wire(node)
51
+ when AsciiChem::Model::Spectrum then spectrum_to_wire(node)
52
+ when AsciiChem::Model::Crystal then crystal_to_wire(node)
53
+ when AsciiChem::Model::ZMatrix then zmatrix_to_wire(node)
54
+ when AsciiChem::Model::Calculation then calculation_to_wire(node)
55
+ else raise ArgumentError, "no wire form for #{node.class}"
56
+ end
57
+ end
58
+
59
+ # wire -> domain ------------------------------------------------
60
+
61
+ def from_wire(wire)
62
+ type = wire.type
63
+ unless CORE_INGEST_TYPES.include?(type)
64
+ raise ArgumentError, "wire ingestion not implemented for #{type.inspect}"
65
+ end
66
+
67
+ send(:"#{type.tr('-', '_')}_from_wire", wire)
68
+ end
69
+
70
+ # Discriminator-routed tree construction from a parsed JSON
71
+ # hash. Per-node deserialisation stays in lutaml-model; this
72
+ # only routes `type` and re-injects routed children into
73
+ # polymorphic collections (which lutaml deserialises flat).
74
+ def wire_tree_from_hash(hash)
75
+ type = hash.fetch("type") { raise KeyError, "wire node missing type discriminator" }
76
+ klass = AsciiChem::Wire::REGISTRY.fetch(type) do
77
+ raise KeyError, "unknown wire node type #{type.inspect}"
78
+ end
79
+
80
+ wire = klass.from_json(JSON.generate(hash))
81
+ case type
82
+ when "formula", "molecule", "group"
83
+ wire.nodes = hash.fetch("nodes", []).map { |child| wire_tree_from_hash(child) }
84
+ when "reaction"
85
+ wire.reactants = hash.fetch("reactants", []).map { |child| wire_tree_from_hash(child) }
86
+ wire.products = hash.fetch("products", []).map { |child| wire_tree_from_hash(child) }
87
+ when "reaction-cascade"
88
+ wire.steps = hash.fetch("steps", []).map { |child| wire_tree_from_hash(child) }
89
+ end
90
+ wire
91
+ end
92
+
93
+ private
94
+
95
+ def formula_to_wire(node)
96
+ AsciiChem::Wire::Formula.new(type: "formula", nodes: node.nodes.map { |n| to_wire(n) })
97
+ end
98
+
99
+ def atom_to_wire(atom)
100
+ AsciiChem::Wire::Atom.new(
101
+ type: "atom",
102
+ element: atom.element,
103
+ isotope: digits_or_nil(atom.isotope), charge: str_or_nil(atom.charge),
104
+ subscript: digits_or_nil(atom.subscript),
105
+ oxidation_state: str_or_nil(atom.oxidation_state),
106
+ lone_pairs: int_or_nil(atom.lone_pairs),
107
+ radical_electrons: int_or_nil(atom.radical_electrons),
108
+ ring_closures: str_or_nil(atom.ring_closures)
109
+ )
110
+ end
111
+
112
+ # Fuzz corpus inputs like `H_{2a}O` parse with the raw braced
113
+ # text in the subscript field (the Text formatter round-trips it
114
+ # verbatim by design). The canonical wire form carries only
115
+ # schema-clean values; junk never enters the wire.
116
+ def digits_or_nil(value)
117
+ str_or_nil(value)&.then { |s| s.match?(/\A\d+\z/) ? s : nil }
118
+ end
119
+
120
+ def str_or_nil(value)
121
+ value.is_a?(String) ? value : nil
122
+ end
123
+
124
+ def int_or_nil(value)
125
+ value.is_a?(Integer) ? value : nil
126
+ end
127
+
128
+ def molecule_to_wire(node)
129
+ AsciiChem::Wire::Molecule.new(
130
+ type: "molecule",
131
+ nodes: node.nodes.map { |n| to_wire(n) },
132
+ coefficient: node.coefficient,
133
+ identifiers: node.identifiers.map { |i| identifier_to_wire(i) }
134
+ )
135
+ end
136
+
137
+ def group_to_wire(node)
138
+ AsciiChem::Wire::Group.new(
139
+ type: "group",
140
+ nodes: node.nodes.map { |n| to_wire(n) },
141
+ multiplicity: node.multiplicity,
142
+ bracket: node.bracket.to_s
143
+ )
144
+ end
145
+
146
+ def bond_to_wire(node)
147
+ AsciiChem::Wire::Bond.new(type: "bond", kind: node.kind.to_s)
148
+ end
149
+
150
+ def identifier_to_wire(node)
151
+ AsciiChem::Wire::Identifier.new(
152
+ type: "identifier",
153
+ value: node.value, convention: node.convention.to_s, dict_ref: node.dict_ref
154
+ )
155
+ end
156
+
157
+ def name_to_wire(node)
158
+ AsciiChem::Wire::Name.new(
159
+ type: "name",
160
+ content: node.content, convention: node.convention, dict_ref: node.dict_ref
161
+ )
162
+ end
163
+
164
+ def reaction_to_wire(node)
165
+ AsciiChem::Wire::Reaction.new(
166
+ type: "reaction",
167
+ reactants: node.reactants.map { |m| molecule_to_wire(m) },
168
+ products: node.products.map { |m| molecule_to_wire(m) },
169
+ arrow: node.arrow.to_s,
170
+ conditions: conditions_to_wire(node.conditions)
171
+ )
172
+ end
173
+
174
+ def conditions_to_wire(conditions)
175
+ return nil unless conditions
176
+
177
+ AsciiChem::Wire::ReactionConditions.new(above: conditions.above, below: conditions.below)
178
+ end
179
+
180
+ def cascade_to_wire(node)
181
+ AsciiChem::Wire::ReactionCascade.new(
182
+ type: "reaction-cascade",
183
+ steps: node.steps.map { |r| reaction_to_wire(r) }
184
+ )
185
+ end
186
+
187
+ def econfig_to_wire(node)
188
+ AsciiChem::Wire::ElectronConfiguration.new(
189
+ type: "electron-configuration",
190
+ orbitals: node.orbitals.map do |(label, count)|
191
+ AsciiChem::Wire::OrbitalOccupancy.new(orbital: label.to_s, occupancy: count.to_s)
192
+ end,
193
+ term_symbol: term_symbol_to_wire(node.term_symbol)
194
+ )
195
+ end
196
+
197
+ def term_symbol_to_wire(term)
198
+ return nil unless term
199
+
200
+ AsciiChem::Wire::TermSymbol.new(
201
+ multiplicity: term.multiplicity.to_s, letter: term.letter.to_s, j_value: term.j_value.to_s
202
+ )
203
+ end
204
+
205
+ def math_to_wire(node)
206
+ AsciiChem::Wire::EmbeddedMath.new(type: "embedded-math", source: node.source)
207
+ end
208
+
209
+ def text_to_wire(node)
210
+ AsciiChem::Wire::Text.new(type: "text", content: node.content)
211
+ end
212
+
213
+ def mechanism_to_wire(node)
214
+ AsciiChem::Wire::Mechanism.new(
215
+ type: "mechanism",
216
+ steps: node.steps.map do |step|
217
+ AsciiChem::Wire::MechanismStep.new(label: step.label, reaction: reaction_to_wire(step.reaction))
218
+ end,
219
+ spectators: node.spectators.map { |m| molecule_to_wire(m) }
220
+ )
221
+ end
222
+
223
+ def spectrum_to_wire(node)
224
+ AsciiChem::Wire::Spectrum.new(
225
+ type: "spectrum",
226
+ technique: node.type,
227
+ params: node.params,
228
+ peaks: node.peaks.map do |peak|
229
+ AsciiChem::Wire::SpectrumPeak.new(
230
+ position: peak.position, intensity: peak.intensity,
231
+ multiplicity: peak.multiplicity, assignment: peak.assignment
232
+ )
233
+ end
234
+ )
235
+ end
236
+
237
+ def crystal_to_wire(node)
238
+ AsciiChem::Wire::Crystal.new(
239
+ type: "crystal",
240
+ name: node.name, a: node.a, b: node.b, c: node.c,
241
+ alpha: node.alpha, beta: node.beta, gamma: node.gamma,
242
+ spacegroup: node.spacegroup,
243
+ atoms: node.atoms.map { |atom| atom_to_wire(atom) }
244
+ )
245
+ end
246
+
247
+ def zmatrix_to_wire(node)
248
+ AsciiChem::Wire::ZMatrix.new(
249
+ type: "zmatrix",
250
+ rows: node.rows.map do |row|
251
+ AsciiChem::Wire::ZRow.new(
252
+ atom: row.atom, ref1: row.ref1, distance: row.distance,
253
+ ref2: row.ref2, angle: row.angle, ref3: row.ref3, dihedral: row.dihedral
254
+ )
255
+ end
256
+ )
257
+ end
258
+
259
+ def calculation_to_wire(node)
260
+ AsciiChem::Wire::Calculation.new(
261
+ type: "calculation",
262
+ method: node.method, basis: node.basis,
263
+ properties: node.properties.map do |prop|
264
+ AsciiChem::Wire::CalculatedProperty.new(
265
+ title: prop.title, value: prop.value, units: prop.units,
266
+ dict_ref: prop.dict_ref, convention: prop.convention
267
+ )
268
+ end
269
+ )
270
+ end
271
+
272
+ # -- ingestion (core set) ----------------------------------------
273
+
274
+ def formula_from_wire(wire)
275
+ AsciiChem::Model::Formula.new(nodes: wire.nodes.map { |n| from_wire(n) })
276
+ end
277
+
278
+ def atom_from_wire(wire)
279
+ AsciiChem::Model::Atom.new(
280
+ element: wire.element, isotope: wire.isotope, subscript: wire.subscript,
281
+ charge: wire.charge, oxidation_state: wire.oxidation_state,
282
+ lone_pairs: wire.lone_pairs, radical_electrons: wire.radical_electrons,
283
+ ring_closures: wire.ring_closures
284
+ )
285
+ end
286
+
287
+ def molecule_from_wire(wire)
288
+ AsciiChem::Model::Molecule.new(
289
+ nodes: Array(wire.nodes).map { |n| from_wire(n) },
290
+ coefficient: wire.coefficient,
291
+ identifiers: Array(wire.identifiers).map { |i| identifier_from_wire(i) }
292
+ )
293
+ end
294
+
295
+ def group_from_wire(wire)
296
+ AsciiChem::Model::Group.new(
297
+ nodes: wire.nodes.map { |n| from_wire(n) },
298
+ multiplicity: wire.multiplicity,
299
+ bracket: wire.bracket&.to_sym
300
+ )
301
+ end
302
+
303
+ def bond_from_wire(wire)
304
+ AsciiChem::Model::Bond.new(kind: wire.kind.to_sym)
305
+ end
306
+
307
+ def identifier_from_wire(wire)
308
+ AsciiChem::Model::Identifier.new(
309
+ value: wire.value, convention: wire.convention, dict_ref: wire.dict_ref
310
+ )
311
+ end
312
+
313
+ def name_from_wire(wire)
314
+ AsciiChem::Model::Name.new(
315
+ content: wire.content, convention: wire.convention, dict_ref: wire.dict_ref
316
+ )
317
+ end
318
+
319
+ def reaction_from_wire(wire)
320
+ conditions = wire.conditions &&
321
+ AsciiChem::Model::Reaction::Conditions.new(above: wire.conditions.above,
322
+ below: wire.conditions.below)
323
+ AsciiChem::Model::Reaction.new(
324
+ reactants: Array(wire.reactants).map { |m| molecule_from_wire(m) },
325
+ products: Array(wire.products).map { |m| molecule_from_wire(m) },
326
+ arrow: wire.arrow.to_sym,
327
+ conditions: conditions
328
+ )
329
+ end
330
+
331
+ def reaction_cascade_from_wire(wire)
332
+ AsciiChem::Model::ReactionCascade.new(
333
+ steps: Array(wire.steps).map { |r| reaction_from_wire(r) }
334
+ )
335
+ end
336
+
337
+ def electron_configuration_from_wire(wire)
338
+ AsciiChem::Model::ElectronConfiguration.new(
339
+ orbitals: Array(wire.orbitals).map { |o| [o.orbital, o.occupancy] },
340
+ term_symbol: wire.term_symbol &&
341
+ AsciiChem::Model::ElectronConfiguration::TermSymbol.new(
342
+ multiplicity: wire.term_symbol.multiplicity,
343
+ letter: wire.term_symbol.letter,
344
+ j_value: wire.term_symbol.j_value
345
+ )
346
+ )
347
+ end
348
+
349
+ def embedded_math_from_wire(wire)
350
+ AsciiChem::Model::EmbeddedMath.new(
351
+ formula: Plurimath::Asciimath.new(wire.source).to_formula,
352
+ source: wire.source
353
+ )
354
+ end
355
+
356
+ def text_from_wire(wire)
357
+ AsciiChem::Model::Text.new(content: wire.content)
358
+ end
359
+ end
360
+ end
361
+ end
data/lib/asciichem.rb CHANGED
@@ -27,9 +27,18 @@ module AsciiChem
27
27
  autoload :RingBonds, "asciichem/ring_bonds"
28
28
  autoload :Transform, "asciichem/transform"
29
29
  autoload :VERSION, "asciichem/version"
30
+ autoload :Wire, "asciichem/wire"
31
+ autoload :WireAdapter, "asciichem/wire_adapter"
30
32
  autoload :XmlBuilder, "asciichem/xml_builder"
31
33
 
32
34
  def self.parse(text)
33
35
  Parser.new(text).parse
34
36
  end
37
+
38
+ # Rebuilds the semantic model from the canonical JSON wire form
39
+ # (asciichem-model v1) — the interchange format every
40
+ # implementation emits.
41
+ def self.from_model_json(json)
42
+ WireAdapter.from_model_json(json)
43
+ end
35
44
  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.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -37,6 +37,26 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '1.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: lutaml-model
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0.8'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '2'
50
+ type: :runtime
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0.8'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '2'
40
60
  - !ruby/object:Gem::Dependency
41
61
  name: nokogiri
42
62
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +113,20 @@ dependencies:
93
113
  - - "~>"
94
114
  - !ruby/object:Gem::Version
95
115
  version: '1.3'
116
+ - !ruby/object:Gem::Dependency
117
+ name: asciichem-model
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: 0.3.4
123
+ type: :development
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: 0.3.4
96
130
  description: AsciiChem is an ASCII syntax for representing chemical formulae, reactions,
97
131
  electron configurations, bonds, crystals, spectra, calculations, Z-matrices, and
98
132
  reaction mechanisms. It parses to a semantic model and renders to MathML, HTML,
@@ -194,6 +228,13 @@ files:
194
228
  - lib/asciichem/ring_bonds.rb
195
229
  - lib/asciichem/transform.rb
196
230
  - lib/asciichem/version.rb
231
+ - lib/asciichem/wire.rb
232
+ - lib/asciichem/wire/base.rb
233
+ - lib/asciichem/wire/chemistry.rb
234
+ - lib/asciichem/wire/core.rb
235
+ - lib/asciichem/wire/extended.rb
236
+ - lib/asciichem/wire/identity.rb
237
+ - lib/asciichem/wire_adapter.rb
197
238
  - lib/asciichem/xml_builder.rb
198
239
  homepage: https://www.asciichem.org
199
240
  licenses:
@@ -218,7 +259,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
218
259
  - !ruby/object:Gem::Version
219
260
  version: '0'
220
261
  requirements: []
221
- rubygems_version: 4.0.16
262
+ rubygems_version: 3.6.9
222
263
  specification_version: 4
223
264
  summary: ASCII syntax for chemistry formulae, reactions, structures, and spectroscopy.
224
265
  test_files: []