asciichem 0.27.0 → 0.28.1

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: 4602bf349585d9d81483e3561d970efd6bb06f3196d30a0a3017df316699664f
4
- data.tar.gz: ea4c38d2af66b75ce2024c5cb1cc866af19584af2edba92b09cc264a7e48037e
3
+ metadata.gz: ccf91a425d3850a5730481c27c475dd79b219479ad91ee41a755ec72c316c75a
4
+ data.tar.gz: f306ca9fa6299d78f3cbabcbb51dfbf6d962c228946406b696608b349eb8ec50
5
5
  SHA512:
6
- metadata.gz: 4dad38021607a88a0f37b90a2320b228d47b6e6197b7102eb48616ea94486ccfc16f953c2c233446ca216eefa1d76aebd0c88b1d40edcb2f582abb56c062e79f
7
- data.tar.gz: 4fc1f074a3b182e2d22c263f09044d873a9d96ba1e627e89780e42977a00aedd0e7142590df6aa645e405e7dcbca89076e505abccc3e1e37be99dddfca527372
6
+ metadata.gz: 3c1823eb43793553108cef0eb28d22c98bbf79dfc275d7181b6b3538a51ea622d8001b3ce8060093ea4dbd4f0e77a33f23ab831bcbaac02accc91d2765c5446c
7
+ data.tar.gz: 38b55cf589f09cc497b8cfecfff3805d254fb80908269caff589a9703309c30c76a5beb1b014f123ba9fc766266640139c97dc0dd8892a8e0165f4fb191bc05f
data/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  All notable changes to AsciiChem are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.28.1] - 2026-09-14
7
+
8
+ ### Fixed
9
+ - Cascade transform is engine-agnostic: CascadeBuilder#canonicalise_hash
10
+ wraps scalar arrow/products values instead of Array(hash), which
11
+ enumerates a Hash. The parsanol 1.3.13 re-check (221/221 corpus-green
12
+ through the unmodified grammar) surfaced the shape; parslet is
13
+ unaffected. Spec'd for all three engine shapes.
14
+
15
+ ## [0.28.0] - 2026-09-14
16
+
17
+ ### Changed
18
+ - Embedded-math MathML now crosses through the mml gem
19
+ (plurimath/mml) in both directions: Plurimath output is parsed
20
+ into a typed Mml::V3 graph and re-serialized by the framework
21
+ before grafting - replacing xpath surgery on the raw string.
22
+ mml (~> 2.3) is now a direct dependency (previously transitive
23
+ via plurimath).
24
+ - New spec contract: every emitted MathML (and every shared-corpus
25
+ golden) parses back through Mml.parse - output is valid per the
26
+ typed MathML contract model, not just well-formed XML.
27
+
6
28
  ## [0.27.0] - 2026-09-14
7
29
 
8
30
  ### Added
@@ -505,3 +527,13 @@ This project follows [Semantic Versioning](https://semver.org/).
505
527
  [0.3.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.2.0...v0.3.0
506
528
  [0.2.0]: https://github.com/asciichem/asciichem-ruby/compare/v0.1.0...v0.2.0
507
529
  [0.1.0]: https://github.com/asciichem/asciichem-ruby/releases/tag/v0.1.0
530
+ ### Changed
531
+ - Embedded-math MathML now crosses through the mml gem (plurimath/mml)
532
+ in both directions: Plurimath output is parsed into a typed
533
+ Mml::V3 graph and re-serialized by the framework before grafting —
534
+ replacing xpath surgery on the raw string. mml (~> 2.3) is now a
535
+ direct dependency (previously transitive via plurimath).
536
+ - New spec contract: every emitted MathML (and every shared-corpus
537
+ golden) parses back through Mml.parse — output is valid per the
538
+ typed MathML contract model, not just well-formed XML.
539
+
data/asciichem.gemspec CHANGED
@@ -36,6 +36,7 @@ Gem::Specification.new do |spec|
36
36
  spec.add_dependency "chemicalml", "~> 0.3.0"
37
37
  spec.add_dependency "elkrb", "~> 1.0"
38
38
  spec.add_dependency "lutaml-model", ">= 0.8", "< 2"
39
+ spec.add_dependency "mml", "~> 2.3"
39
40
  spec.add_dependency "nokogiri", "~> 1.16"
40
41
  spec.add_dependency "parslet", "~> 2.0"
41
42
  spec.add_dependency "relaton-bib", ">= 0.1", "< 2"
data/benchmarks/README.md CHANGED
@@ -15,6 +15,7 @@ WORKLOAD = ["H_2O", "Ca^2+", "SO_4^2-", "(R)-CH_3CH(OH)COOH",
15
15
  |---|---|---|---|
16
16
  | Ruby (parslet), 3.4.8 arm64 | 29.1 ms | ~2.9 ms | `bundle exec ruby benchmarks/engines.rb` |
17
17
  | Ruby + parse+text | 35.3 ms | ~3.5 ms | round-trip adds the formatter |
18
+ | Ruby (parsanol compat, :ruby), 1.3.13 | 19.9 ms | ~2.0 ms | `benchmarks/parsanol_recheck.rb`, same session as the 11.8 ms parslet baseline below |
18
19
  | TypeScript (peggy), Node 24 | 0.19 ms | ~19 µs | `npm run bench` (asciichem-ts) |
19
20
  | Python (RD), 3.10 | 3.94 ms | ~394 µs | `python benchmarks/engines.py` (asciichem-py) |
20
21
 
@@ -54,3 +55,33 @@ upstream in parsanol-ruby#25):
54
55
  Revisit trigger unchanged: engage the native backend for full
55
56
  grammars, fix the repetition-termination bug, and beat parslet on
56
57
  this workload — then re-run the corpus against the port.
58
+
59
+ ### Re-check (2026-09-14, parsanol 1.3.13)
60
+
61
+ The upstream "native by default" + RepetitionTag work landed, so the
62
+ revisit trigger was tested (`benchmarks/parsanol_recheck.rb`):
63
+
64
+ 1. **The repetition-termination bug is fixed.** `SO_4^2-` parses and
65
+ round-trips, and the unmodified grammar passes the entire shared
66
+ corpus through the `Parsanol::Parslet` compat layer —
67
+ **221/221** parse/reject/round-trip cases identical to parslet.
68
+ One divergence surfaced on our side and is fixed in the gem:
69
+ parsanol's transform delivers cascade tail segments as scalar
70
+ hashes, and `CascadeBuilder#canonicalise_hash` used `Array(hash)`
71
+ (which enumerates a Hash instead of wrapping it) — now wraps
72
+ explicitly, engine-agnostic.
73
+ 2. **Perf (compat, :ruby forced):** 19.9 ms vs 11.8 ms per 10-input
74
+ pass in the same session — **~1.7x slower than parslet** (down
75
+ from ~6x in the first investigation). Correct but not a win.
76
+ 3. **Native still cannot serialize full parslet grammars** — two
77
+ upstream bugs (reported in parsanol-ruby#25):
78
+ `native.rb` never loads `native/dynamic` (NameError silently
79
+ falls back to :ruby), and `Dynamic.register` never increments
80
+ `@next_id`, so the second lazily-bound rule panics the Rust core
81
+ with "callback ID 1000000 is already registered".
82
+
83
+ **Verdict: still not adopted — but one small upstream fix away from
84
+ a meaningful re-measure.** Corpus correctness is already there; the
85
+ native path is the whole point and remains unmeasurable until
86
+ serialization survives a multi-rule grammar.
87
+
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Parsanol re-check (parsanol-ruby 1.3.13, post-issue-25): runs the
4
+ # UNMODIFIED AsciiChem grammar over the Parsanol engine via the
5
+ # Parslet compat shim, then (1) gates on the shared corpus, (2) gates
6
+ # on the issue-25 EOF repro, (3) measures against the parslet path.
7
+ #
8
+ # Native mode cannot serialize this grammar today (two upstream bugs:
9
+ # native.rb never loads native/dynamic; Dynamic.register never
10
+ # increments @next_id so the second callback panics the Rust core —
11
+ # parsanol-ruby#25). The measurement therefore forces :ruby, the
12
+ # only working mode for full parslet grammars via the shim.
13
+ #
14
+ # Run from asciichem-ruby/:
15
+ # ruby -I /tmp/parsanol_spike -I ../parsanol/parsanol-ruby/lib benchmarks/parsanol_recheck.rb
16
+ require "benchmark/ips"
17
+ require "asciichem"
18
+ require "json"
19
+
20
+ Parsanol::Native.singleton_class.define_method(:available?) { false } # spike: force :ruby
21
+
22
+ puts "parsanol #{Parsanol::VERSION} | parslet-compat Parser=#{Parsanol::Parslet::Parser}"
23
+ puts "AsciiChem::Grammar superclass: #{AsciiChem::Grammar.superclass}"
24
+
25
+ # -- 1. Issue-25 repro: repeat-of-maybe at end of input --------------
26
+ begin
27
+ formula = AsciiChem.parse("SO_4^2-")
28
+ puts "issue-25 repro SO_4^2-: PARSES -> #{formula.to_text.inspect}"
29
+ rescue AsciiChem::ParseError => e
30
+ puts "issue-25 repro SO_4^2-: FAILS -> #{e.message[0, 100]}"
31
+ end
32
+
33
+ # -- 2. Shared-corpus gate --------------------------------------------
34
+ corpus_dir = File.expand_path("../../asciichem-tests/corpus/fixtures", __dir__)
35
+ cases = Dir[File.join(corpus_dir, "*.json")].sort.flat_map { |p| JSON.parse(File.read(p)) }
36
+ parser_cases = cases.select { |c| c.key?("input") && !c.key?("lint") && !c.key?("convention") }
37
+
38
+ pass = fail_parse = fail_reject = fail_roundtrip = 0
39
+ parser_cases.each do |fixture|
40
+ input = fixture.fetch("input")
41
+ if fixture.fetch("parses")
42
+ begin
43
+ formula = AsciiChem.parse(input)
44
+ if fixture["roundTrip"] && formula.to_text != input
45
+ fail_roundtrip += 1
46
+ puts " ROUNDTRIP DIFF: #{input.inspect} -> #{formula.to_text.inspect}" if fail_roundtrip <= 5
47
+ end
48
+ pass += 1
49
+ rescue AsciiChem::ParseError, Parslet::ParseFailed => e
50
+ fail_parse += 1
51
+ puts " PARSE FAIL: #{input.inspect} -> #{e.message[0, 90]}" if fail_parse <= 8
52
+ end
53
+ else
54
+ begin
55
+ AsciiChem.parse(input)
56
+ fail_reject += 1
57
+ puts " SHOULD REJECT: #{input.inspect}" if fail_reject <= 8
58
+ rescue AsciiChem::ParseError, Parslet::ParseFailed
59
+ pass += 1
60
+ end
61
+ end
62
+ end
63
+ total = parser_cases.length
64
+ puts format("corpus gate: %d/%d ok (parse-fails %d, should-reject %d, roundtrip-diffs %d)",
65
+ pass, total, fail_parse, fail_reject, fail_roundtrip)
66
+
67
+ # -- 3. Performance ----------------------------------------------------
68
+ WORKLOAD = [
69
+ "H_2O", "Ca^2+", "SO_4^2-", "(R)-CH_3CH(OH)COOH",
70
+ "2H_2 + O_2 -> 2H_2O", "N_2 + 3H_2 <=>[Fe][400C] 2NH_3",
71
+ "C1-C-C-C-C-C1", "CH_3-CH_2-OH",
72
+ '^14C @name("carbon-14") @cas("14104-86-4")',
73
+ "A ->[heat] B ->[cool] C",
74
+ ].freeze
75
+
76
+ Benchmark.ips do |x|
77
+ x.report("parsanol parse x10") { WORKLOAD.each { |s| AsciiChem.parse(s) } }
78
+ x.compare!
79
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "nokogiri"
4
+ require "mml"
4
5
 
5
6
  module AsciiChem
6
7
  module Formatter
@@ -196,17 +197,27 @@ module AsciiChem
196
197
  end
197
198
 
198
199
  def visit_embedded_math(em)
199
- # Strip the outer <math> wrapper so the embedded fragment slots
200
- # into our surrounding <mrow>.
201
- fragment = em.formula.to_mathml
202
- parsed = Nokogiri::XML(fragment)
203
- math = parsed.at_xpath("//m:math", m: MATHML_NS)
204
- return el("mrow") unless math
205
-
206
- # Detach children into a fresh <mrow>.
200
+ # Embedded math crosses through the mml gem in both directions:
201
+ # Plurimath's MathML string is parsed into a typed Mml::V3
202
+ # graph (the MathML contract model), and each top-level child
203
+ # is re-serialized by the framework before grafting — never
204
+ # ad-hoc xpath surgery on the string.
205
+ math = Mml.parse(em.formula.to_mathml, version: 3)
207
206
  mrow = el("mrow")
208
- math.children.each { |c| mrow.add_child(c.dup) }
207
+ math.element_order.each do |entry|
208
+ next unless entry.respond_to?(:name)
209
+
210
+ attr_name = xml_element_attribute(math.class, entry.name)
211
+ next unless attr_name
212
+
213
+ Array(math.public_send(attr_name)).each do |child|
214
+ fragment = Nokogiri::XML::DocumentFragment.parse(child.to_xml)
215
+ fragment.children.each { |node| mrow.add_child(node) }
216
+ end
217
+ end
209
218
  mrow
219
+ rescue Mml::Error, Lutaml::Model::Error, Nokogiri::XML::SyntaxError
220
+ el("mrow")
210
221
  end
211
222
 
212
223
  def visit_text(text)
@@ -309,6 +320,14 @@ module AsciiChem
309
320
 
310
321
  private
311
322
 
323
+ # Symbol of the model attribute an XML child element name maps
324
+ # to on a Mml class (e.g. "mstyle" => :mstyle_value), via the
325
+ # lutaml-model XML mapping — no name-munging.
326
+ def xml_element_attribute(klass, element_name)
327
+ rule = klass.mappings_for(:xml).elements.find { |r| r.name == element_name }
328
+ rule&.to
329
+ end
330
+
312
331
  def render_node(node)
313
332
  node.accept(self)
314
333
  end
@@ -830,11 +830,18 @@ module AsciiChem
830
830
 
831
831
  def canonicalise_hash(hash)
832
832
  first = hash[:first]
833
- arrows = Array(hash[:arrow])
834
- products = Array(hash[:products])
835
- tail = arrows.zip(products)
833
+ # Wrap, never Array(): Array(hash) on a scalar Hash value
834
+ # (the shape parsanol's transform delivers) would enumerate
835
+ # key/value pairs instead of wrapping it.
836
+ tail = wrap(hash[:arrow]).zip(wrap(hash[:products]))
836
837
  [first, tail]
837
838
  end
839
+
840
+ def wrap(value)
841
+ return [] if value.nil?
842
+
843
+ value.is_a?(Array) ? value : [value]
844
+ end
838
845
  end
839
846
 
840
847
  # Maps the captured stereo letter to the model's stereo symbol.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AsciiChem
4
- VERSION = "0.27.0"
4
+ VERSION = "0.28.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asciichem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.27.0
4
+ version: 0.28.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -57,6 +57,20 @@ dependencies:
57
57
  - - "<"
58
58
  - !ruby/object:Gem::Version
59
59
  version: '2'
60
+ - !ruby/object:Gem::Dependency
61
+ name: mml
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '2.3'
67
+ type: :runtime
68
+ prerelease: false
69
+ version_requirements: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - "~>"
72
+ - !ruby/object:Gem::Version
73
+ version: '2.3'
60
74
  - !ruby/object:Gem::Dependency
61
75
  name: nokogiri
62
76
  requirement: !ruby/object:Gem::Requirement
@@ -176,6 +190,7 @@ files:
176
190
  - benchmarks/RESULTS.md
177
191
  - benchmarks/benchmark.rb
178
192
  - benchmarks/engines.rb
193
+ - benchmarks/parsanol_recheck.rb
179
194
  - exe/asciichem
180
195
  - lib/asciichem.rb
181
196
  - lib/asciichem/citation.rb