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 +4 -4
- data/CHANGELOG.md +32 -0
- data/asciichem.gemspec +1 -0
- data/benchmarks/README.md +31 -0
- data/benchmarks/parsanol_recheck.rb +79 -0
- data/lib/asciichem/formatter/mathml.rb +28 -9
- data/lib/asciichem/transform.rb +10 -3
- data/lib/asciichem/version.rb +1 -1
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ccf91a425d3850a5730481c27c475dd79b219479ad91ee41a755ec72c316c75a
|
|
4
|
+
data.tar.gz: f306ca9fa6299d78f3cbabcbb51dfbf6d962c228946406b696608b349eb8ec50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
200
|
-
# into
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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.
|
|
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
|
data/lib/asciichem/transform.rb
CHANGED
|
@@ -830,11 +830,18 @@ module AsciiChem
|
|
|
830
830
|
|
|
831
831
|
def canonicalise_hash(hash)
|
|
832
832
|
first = hash[:first]
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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.
|
data/lib/asciichem/version.rb
CHANGED
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.
|
|
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
|