asciichem 0.28.1 → 0.29.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: ccf91a425d3850a5730481c27c475dd79b219479ad91ee41a755ec72c316c75a
4
- data.tar.gz: f306ca9fa6299d78f3cbabcbb51dfbf6d962c228946406b696608b349eb8ec50
3
+ metadata.gz: 106d03bf9d192f66b4f9ade534ed84f8dcc5cbf0ceeb07d45b633c353fc92aaa
4
+ data.tar.gz: 36c047aa3f26f73f94cc698eb905a939025feb9fc34fb86b67163cf3a93f7fbe
5
5
  SHA512:
6
- metadata.gz: 3c1823eb43793553108cef0eb28d22c98bbf79dfc275d7181b6b3538a51ea622d8001b3ce8060093ea4dbd4f0e77a33f23ab831bcbaac02accc91d2765c5446c
7
- data.tar.gz: 38b55cf589f09cc497b8cfecfff3805d254fb80908269caff589a9703309c30c76a5beb1b014f123ba9fc766266640139c97dc0dd8892a8e0165f4fb191bc05f
6
+ metadata.gz: b9215fa19d5dc3a65d82cfbe5cdfd4f837bd136cbb18cb0241654e7307a314c42088a870a76c3393858bd285e8e5834a95de136849c6f4d1ee4d2469fe616731
7
+ data.tar.gz: f7654b2d8257c634e7be6b514011b5875c8060886204c43dd9293b982c8cd241773fb92958d8772d0e28ec07ce50ad82753aba085469e0c3edbc03446201c3bc
@@ -13,9 +13,17 @@ jobs:
13
13
  matrix:
14
14
  ruby: ["3.3", "3.4"]
15
15
  steps:
16
- - uses: actions/checkout@v4
16
+ - uses: actions/checkout@v7
17
17
  - name: Clone conformance corpus (asciichem-tests)
18
- run: git clone --depth 1 https://github.com/asciichem/asciichem-tests.git ../asciichem-tests
18
+ uses: actions/checkout@v7
19
+ with:
20
+ repository: asciichem/asciichem-tests
21
+ path: .asciichem-tests
22
+ - name: Point the suite at the corpus and record its version
23
+ run: |
24
+ echo "ASCIICHEM_CORPUS=$(pwd)/.asciichem-tests/corpus/fixtures" >> "$GITHUB_ENV"
25
+ git -C .asciichem-tests fetch --depth 1 --tags --quiet
26
+ echo "ASCIICHEM_CORPUS_VERSION=$(git -C .asciichem-tests describe --tags --abbrev=0 2>/dev/null || echo main)" >> "$GITHUB_ENV"
19
27
  - uses: ruby/setup-ruby@v1
20
28
  with:
21
29
  ruby-version: ${{ matrix.ruby }}
@@ -18,7 +18,7 @@ jobs:
18
18
  contents: read
19
19
  id-token: write
20
20
  steps:
21
- - uses: actions/checkout@v4
21
+ - uses: actions/checkout@v7
22
22
  with:
23
23
  ref: main
24
24
  persist-credentials: false
data/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
3
3
  All notable changes to AsciiChem are documented here.
4
4
  This project follows [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.29.0] - 2026-09-15
7
+
8
+ ### Added
9
+ - Opt-in Parsanol parsing engine (TODO.impl 63/64; parsanol-ruby#25):
10
+ `AsciiChem::Engine.use(:parsanol)` runs the SAME grammar and
11
+ transform (extracted into backend-neutral GrammarRules /
12
+ TransformRules modules) over Parsanol's Rust-backed
13
+ parslet-compat layer - the full suite (1981 examples) passes
14
+ identically under either engine, at ~2.4x parse speed
15
+ (219 vs 90 i/s on the benchmark workload). Parsanol is a soft
16
+ dependency (gemspec unchanged); absent gem raises guidance.
17
+ ASCIICHEM_ENGINE=parsanol selects it for test runs.
18
+
19
+ ### Changed
20
+ - Cascade legs are captured as one :segments repeat (the
21
+ electron-config pattern) so every engine arrays them; parsanol
22
+ merges - and overwrites - bare repeated sibling captures,
23
+ silently dropping legs (reported upstream). Transform
24
+ canonicaliser consumes the segments shape; spec'd for scalar and
25
+ array forms.
26
+
27
+ ## [0.28.2] - 2026-09-14
28
+
29
+ ### Fixed
30
+ - Conformance reports now name the corpus version CI actually ran
31
+ (derived from the cloned asciichem-tests tag) instead of a stale
32
+ default. Pairs with asciichem-tests v0.4.0 (MathML golden suite).
33
+
6
34
  ## [0.28.1] - 2026-09-14
7
35
 
8
36
  ### Fixed
data/benchmarks/README.md CHANGED
@@ -85,3 +85,28 @@ a meaningful re-measure.** Corpus correctness is already there; the
85
85
  native path is the whole point and remains unmeasurable until
86
86
  serialization survives a multi-rule grammar.
87
87
 
88
+ ### Re-check 3 (2026-09-15, parsanol 1.3.15)
89
+
90
+ The mode-routing/VM rework landed; native now engages for the full
91
+ grammar (`PARSANOL_MODE=native` in `benchmarks/parsanol_recheck.rb`,
92
+ fork-per-case gate so Rust aborts are reported, not fatal):
93
+
94
+ - **219/221 corpus cases green under native** — every accept case
95
+ except the two embedded-math inputs, and all 51 rejects clean
96
+ - **3.2x faster than parslet** on the workload (4.28 ms vs 13.64 ms
97
+ per 10-input pass, same session, ±3.0%)
98
+ - The two failures are the embedded-math grammar paths hitting
99
+ `serialize_dynamic` — the still-unfixed `@next_id` collision from
100
+ the re-check above (manifests as the Rust panic or a Ruby-side
101
+ `NoMethodError` on the native error path). Upstream thread:
102
+ parsanol-ruby#25 (third comment).
103
+ - Separately noted upstream: `H2` / `_2O` are accepted under native
104
+ but rejected under parslet (optimizer Str/Re run-merging semantics;
105
+ no corpus case covers these spellings today).
106
+
107
+ **Verdict: one upstream one-liner from adoption evaluation.** With
108
+ `@next_id += 1` fixed, the entire corpus passes under native at
109
+ 3x parslet speed — at that point the decision is whether to make the
110
+ engine switchable (opt-in, soft dependency) in the gem.
111
+
112
+
@@ -1,26 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Parsanol re-check (parsanol-ruby 1.3.13, post-issue-25): runs the
3
+ # Parsanol re-check (parsanol-ruby 1.3.15, issue-25 thread): runs the
4
4
  # UNMODIFIED AsciiChem grammar over the Parsanol engine via the
5
5
  # Parslet compat shim, then (1) gates on the shared corpus, (2) gates
6
6
  # on the issue-25 EOF repro, (3) measures against the parslet path.
7
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.
8
+ # Mode: native by default (PARSOLAN_MODE=native — default routing);
9
+ # PARSANOL_MODE=ruby forces the pure-Ruby engine. As of 1.3.15 the
10
+ # native backend parses 169/170 corpus accepts + all 51 rejects at
11
+ # ~2.3x parslet speed; the single fatal case is embedded-math input,
12
+ # which panics the Rust core on the known @next_id collision
13
+ # (parsanol-ruby#25) — run that one in :ruby until upstream fixes it.
13
14
  #
14
- # Run from asciichem-ruby/:
15
+ # Run from asciichem-ruby/ with the shim dir prepended:
15
16
  # ruby -I /tmp/parsanol_spike -I ../parsanol/parsanol-ruby/lib benchmarks/parsanol_recheck.rb
16
17
  require "benchmark/ips"
17
18
  require "asciichem"
18
19
  require "json"
19
20
 
20
- Parsanol::Native.singleton_class.define_method(:available?) { false } # spike: force :ruby
21
-
22
21
  puts "parsanol #{Parsanol::VERSION} | parslet-compat Parser=#{Parsanol::Parslet::Parser}"
23
22
  puts "AsciiChem::Grammar superclass: #{AsciiChem::Grammar.superclass}"
23
+ puts "mode: #{ENV.fetch("PARSANOL_MODE", "native")}"
24
+
25
+ if ENV.fetch("PARSANOL_MODE", "native") == "ruby"
26
+ Parsanol::Native.singleton_class.define_method(:available?) { false }
27
+ end
24
28
 
25
29
  # -- 1. Issue-25 repro: repeat-of-maybe at end of input --------------
26
30
  begin
@@ -31,38 +35,66 @@ rescue AsciiChem::ParseError => e
31
35
  end
32
36
 
33
37
  # -- 2. Shared-corpus gate --------------------------------------------
38
+ # Each case runs in a forked child: a Rust panic aborts the child
39
+ # process (unrescuable in Ruby), and the parent reports it by name
40
+ # instead of dying.
34
41
  corpus_dir = File.expand_path("../../asciichem-tests/corpus/fixtures", __dir__)
35
42
  cases = Dir[File.join(corpus_dir, "*.json")].sort.flat_map { |p| JSON.parse(File.read(p)) }
36
43
  parser_cases = cases.select { |c| c.key?("input") && !c.key?("lint") && !c.key?("convention") }
37
44
 
38
- pass = fail_parse = fail_reject = fail_roundtrip = 0
45
+ def run_in_child
46
+ reader, writer = IO.pipe
47
+ pid = fork do
48
+ reader.close
49
+ Marshal.dump(yield, writer)
50
+ rescue StandardError => e
51
+ Marshal.dump({ exception: e.class.name, message: e.message }, writer)
52
+ ensure
53
+ writer.close
54
+ end
55
+ writer.close
56
+ payload = Marshal.load(reader)
57
+ reader.close
58
+ _, status = Process.waitpid2(pid)
59
+ [payload, status]
60
+ end
61
+
62
+ pass = fail_parse = fail_reject = fail_roundtrip = fatal = 0
39
63
  parser_cases.each do |fixture|
40
64
  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
65
+ payload, status = run_in_child do
66
+ formula = AsciiChem.parse(input)
67
+ { text: (formula.to_text if fixture["roundTrip"]) }
68
+ end
69
+ if status.signaled? || !status.success?
70
+ fatal += 1
71
+ puts " FATAL (child #{status.exitstatus ? "exit #{status.exitstatus}" : "aborted"}): #{fixture["id"]} #{input.inspect}" if fatal <= 8
72
+ next
73
+ end
74
+ if payload.key?(:exception)
75
+ if fixture.fetch("parses")
50
76
  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
77
+ puts " PARSE FAIL: #{input.inspect} -> #{payload[:message][0, 90]}" if fail_parse <= 8
78
+ else
59
79
  pass += 1
60
80
  end
81
+ next
82
+ end
83
+ unless fixture.fetch("parses")
84
+ fail_reject += 1
85
+ puts " SHOULD REJECT: #{input.inspect}" if fail_reject <= 8
86
+ next
87
+ end
88
+ if fixture["roundTrip"] && payload[:text] != input
89
+ fail_roundtrip += 1
90
+ puts " ROUNDTRIP DIFF: #{input.inspect} -> #{payload[:text].inspect}" if fail_roundtrip <= 5
91
+ next
61
92
  end
93
+ pass += 1
62
94
  end
63
95
  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)
96
+ puts format("corpus gate: %d/%d ok (parse-fails %d, should-reject %d, roundtrip-diffs %d, fatal %d)",
97
+ pass, total, fail_parse, fail_reject, fail_roundtrip, fatal)
66
98
 
67
99
  # -- 3. Performance ----------------------------------------------------
68
100
  WORKLOAD = [
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Opt-in soft dependency: fail with guidance, never silently.
4
+ begin
5
+ require 'parsanol/parslet'
6
+ # parsanol-ruby 1.3.16's native.rb references
7
+ # Parsanol::Native::Dynamic from the serializer without loading it
8
+ # (parsanol-ruby#25).
9
+ require 'parsanol/native/dynamic'
10
+ rescue LoadError => e
11
+ raise AsciiChem::Engine::NotAvailableError,
12
+ 'the parsanol engine requires the parsanol gem — add gem "parsanol", "~> 1.3.16" ' \
13
+ "to your Gemfile (#{e.message})"
14
+ end
15
+
16
+ module AsciiChem
17
+ module Engine
18
+ # The Parsanol engine: the SAME rules (GrammarRules /
19
+ # TransformRules) over Parsanol's Rust-backed parslet-compat
20
+ # layer. Corpus-identical to :parslet (221/221 byte-for-byte,
21
+ # benchmarks/parsanol_recheck.rb) at ~3x parse speed.
22
+ class ParsanolEngine
23
+ # Backend twins of the reference classes; the shared rules
24
+ # modules are the single source of grammar truth.
25
+ class Grammar < ::Parsanol::Parslet::Parser
26
+ include AsciiChem::GrammarRules
27
+ end
28
+
29
+ # Transform twin; #apply normalizes the engine's merged sibling
30
+ # captures before the shared rules run (see split_merged_formula).
31
+ class Transform < ::Parsanol::Parslet::Transform
32
+ include AsciiChem::TransformRules
33
+
34
+ # Engine divergence seam: parslet yields repeated sibling
35
+ # node captures as an array of single-key hashes; parsanol
36
+ # merges them into one hash, which no rule can match. Split
37
+ # the merged form back before the shared rules run, so the
38
+ # rules stay engine-agnostic (single source of truth).
39
+ def apply(tree, context = nil)
40
+ super(ParsanolEngine.split_merged_formula(tree), context)
41
+ end
42
+ end
43
+
44
+ class << self
45
+ # NODE_STARTERS mirrors the grammar's `node` alternatives
46
+ # (grammar_rules.rb): the capture key each alternative
47
+ # produces FIRST. A new node group begins at each starter;
48
+ # continuation keys (arrow/products, coefficient/annotations,
49
+ # crystal_name/params/body, ...) attach to the current group.
50
+ NODE_STARTERS = %i[
51
+ cascade reactants electron_config crystal_node spectrum_node
52
+ calc_node zmatrix_node mechanism_node mol units math_source text_run
53
+ ].freeze
54
+
55
+ # Grammar truth: a molecule node is {coefficient? stereo? units}
56
+ # — coefficient/stereo may LEGITIMATELY precede :units in one
57
+ # node. They are prefixes, not new-node markers.
58
+ PREFIX_KEYS = %i[coefficient stereo].freeze
59
+
60
+ def split_merged_formula(node)
61
+ case node
62
+ when Hash
63
+ node.to_h { |key, value| [key, split_formula_value(key, value)] }
64
+ when Array
65
+ node.map { |member| split_merged_formula(member) }
66
+ else
67
+ node
68
+ end
69
+ end
70
+
71
+ def grammar
72
+ Grammar
73
+ end
74
+
75
+ def transform
76
+ Transform
77
+ end
78
+
79
+ def parse_failed
80
+ ::Parsanol::ParseFailed
81
+ end
82
+
83
+ def grammar_instance
84
+ @grammar_instance ||= grammar.new
85
+ end
86
+
87
+ def transform_instance
88
+ @transform_instance ||= transform.new
89
+ end
90
+
91
+ private
92
+
93
+ def split_formula_value(key, value)
94
+ if key == :formula && value.is_a?(Hash) && value.length > 1
95
+ groups = []
96
+ value.each do |k, v|
97
+ groups << {} if new_group?(groups, k)
98
+ groups.last[k] = split_merged_formula(v)
99
+ end
100
+ groups.length == 1 ? groups.first : groups
101
+ else
102
+ split_merged_formula(value)
103
+ end
104
+ end
105
+
106
+ def new_group?(groups, key)
107
+ return true if groups.empty?
108
+
109
+ return false unless NODE_STARTERS.include?(key)
110
+
111
+ # :units continues a group that so far holds only molecule
112
+ # prefixes ({coefficient:...} / {stereo:...}).
113
+ !(key == :units && groups.last.keys.all? { |pk| PREFIX_KEYS.include?(pk) })
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ module Engine
5
+ # The reference engine: pure-Ruby parslet. Zero extra
6
+ # dependencies; Grammar and Transform as always.
7
+ class ParsletEngine
8
+ class << self
9
+ def grammar
10
+ Grammar
11
+ end
12
+
13
+ def transform
14
+ Transform
15
+ end
16
+
17
+ def parse_failed
18
+ Parslet::ParseFailed
19
+ end
20
+
21
+ # Engines memoize their grammar/transform instances — they are
22
+ # stateless and expensive to construct (benchmark: ~15%
23
+ # throughput on repeated parses).
24
+ def grammar_instance
25
+ @grammar_instance ||= grammar.new
26
+ end
27
+
28
+ def transform_instance
29
+ @transform_instance ||= transform.new
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsciiChem
4
+ # Parsing engine selector (TODO.impl 63/64; parsanol-ruby#25).
5
+ #
6
+ # The reference engine is :parslet (pure Ruby, zero extra
7
+ # dependencies) and remains the default. The :parsanol engine runs
8
+ # the SAME rules (GrammarRules/TransformRules) over Parsanol's
9
+ # Rust-backed parslet-compat layer — the shared corpus passes
10
+ # byte-for-byte (221/221) at ~3x parslet speed.
11
+ #
12
+ # Parsanol is an opt-in soft dependency; the gemspec is unchanged:
13
+ #
14
+ # # Gemfile
15
+ # gem "parsanol", "~> 1.3.16"
16
+ #
17
+ # require "asciichem/engine/parsanol_engine"
18
+ # AsciiChem::Engine.use(:parsanol)
19
+ # AsciiChem.parse("H_2O") # parsed by the Rust core
20
+ #
21
+ # The active engine may also be selected via ASCIICHEM_ENGINE
22
+ # (a testing seam, mirroring ASCIICHEM_CORPUS):
23
+ #
24
+ # ASCIICHEM_ENGINE=parsanol bundle exec rspec
25
+ #
26
+ module Engine
27
+ class Error < AsciiChem::Error; end
28
+
29
+ # The named engine exists but its gem is not installed.
30
+ class NotAvailableError < Error; end
31
+
32
+ autoload :ParsletEngine, 'asciichem/engine/parslet_engine'
33
+ autoload :ParsanolEngine, 'asciichem/engine/parsanol_engine'
34
+
35
+ class << self
36
+ # Selects the engine by name. Returns the engine class.
37
+ def use(name)
38
+ @current = engine_for(name)
39
+ end
40
+
41
+ # The active engine. Defaults to :parslet, overridable once via
42
+ # ASCIICHEM_ENGINE at first use.
43
+ def current
44
+ @current ||= engine_for(ENV.fetch('ASCIICHEM_ENGINE', 'parslet'))
45
+ end
46
+
47
+ private
48
+
49
+ def engine_for(name)
50
+ case name.to_s
51
+ when 'parslet' then ParsletEngine
52
+ when 'parsanol' then ParsanolEngine
53
+ else
54
+ raise Error, "unknown engine #{name.inspect} (available: parslet, parsanol)"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end