plurimath 0.11.12 → 0.11.13

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.
Files changed (170) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-tests.yml +7 -0
  3. data/.github/workflows/shared-testsuite.yml +24 -0
  4. data/.gitmodules +3 -0
  5. data/.rspec-opal +3 -0
  6. data/Gemfile +18 -0
  7. data/README.adoc +297 -3
  8. data/docs/catalog.adoc +73 -0
  9. data/docs/uncertainty-asciimath-tree.md +92 -0
  10. data/docs/uncertainty.adoc +522 -0
  11. data/lib/plurimath/asciimath/parse.rb +43 -226
  12. data/lib/plurimath/asciimath/parser.rb +58 -1
  13. data/lib/plurimath/asciimath/slice_adapter.rb +138 -0
  14. data/lib/plurimath/asciimath/transform.rb +218 -0
  15. data/lib/plurimath/asciimath.rb +1 -0
  16. data/lib/plurimath/catalog/entry.rb +109 -0
  17. data/lib/plurimath/catalog/validation.rb +109 -0
  18. data/lib/plurimath/catalog.rb +96 -0
  19. data/lib/plurimath/cli/convert.rb +40 -0
  20. data/lib/plurimath/cli/helpers.rb +49 -0
  21. data/lib/plurimath/cli/render.rb +64 -0
  22. data/lib/plurimath/cli.rb +55 -57
  23. data/lib/plurimath/documentation.rb +99 -0
  24. data/lib/plurimath/errors/ambiguous_decimal_error.rb +19 -0
  25. data/lib/plurimath/errors/invalid_uncertainty.rb +18 -0
  26. data/lib/plurimath/errors/missing_dependency_error.rb +23 -0
  27. data/lib/plurimath/errors/rendering_failed.rb +17 -0
  28. data/lib/plurimath/errors/rendering_unavailable.rb +25 -0
  29. data/lib/plurimath/errors/unsupported_render_format.rb +18 -0
  30. data/lib/plurimath/errors/unsupported_semantic_tree_error.rb +12 -0
  31. data/lib/plurimath/errors.rb +5 -0
  32. data/lib/plurimath/math/evaluation/evaluator.rb +48 -0
  33. data/lib/plurimath/math/evaluation/expression_parser.rb +29 -20
  34. data/lib/plurimath/math/formula/class_tree.rb +92 -0
  35. data/lib/plurimath/math/formula.rb +43 -2
  36. data/lib/plurimath/math/function/abs.rb +6 -0
  37. data/lib/plurimath/math/function/arccos.rb +6 -0
  38. data/lib/plurimath/math/function/arcsin.rb +6 -0
  39. data/lib/plurimath/math/function/arctan.rb +6 -0
  40. data/lib/plurimath/math/function/arg.rb +20 -0
  41. data/lib/plurimath/math/function/bar.rb +6 -0
  42. data/lib/plurimath/math/function/base.rb +6 -0
  43. data/lib/plurimath/math/function/binary_function.rb +4 -0
  44. data/lib/plurimath/math/function/cancel.rb +6 -0
  45. data/lib/plurimath/math/function/ceil.rb +6 -0
  46. data/lib/plurimath/math/function/color.rb +12 -0
  47. data/lib/plurimath/math/function/cos.rb +6 -0
  48. data/lib/plurimath/math/function/cosh.rb +6 -0
  49. data/lib/plurimath/math/function/cot.rb +6 -0
  50. data/lib/plurimath/math/function/coth.rb +6 -0
  51. data/lib/plurimath/math/function/csc.rb +6 -0
  52. data/lib/plurimath/math/function/csch.rb +6 -0
  53. data/lib/plurimath/math/function/ddot.rb +6 -0
  54. data/lib/plurimath/math/function/deg.rb +6 -0
  55. data/lib/plurimath/math/function/det.rb +6 -0
  56. data/lib/plurimath/math/function/dim.rb +6 -0
  57. data/lib/plurimath/math/function/dot.rb +6 -0
  58. data/lib/plurimath/math/function/exp.rb +6 -0
  59. data/lib/plurimath/math/function/fenced.rb +6 -0
  60. data/lib/plurimath/math/function/floor.rb +6 -0
  61. data/lib/plurimath/math/function/font_style/bold-fraktur.rb +6 -0
  62. data/lib/plurimath/math/function/font_style/bold-italic.rb +6 -0
  63. data/lib/plurimath/math/function/font_style/bold-sans-serif.rb +6 -0
  64. data/lib/plurimath/math/function/font_style/bold-script.rb +6 -0
  65. data/lib/plurimath/math/function/font_style/bold.rb +6 -0
  66. data/lib/plurimath/math/function/font_style/double_struck.rb +6 -0
  67. data/lib/plurimath/math/function/font_style/fraktur.rb +6 -0
  68. data/lib/plurimath/math/function/font_style/italic.rb +6 -0
  69. data/lib/plurimath/math/function/font_style/monospace.rb +6 -0
  70. data/lib/plurimath/math/function/font_style/normal.rb +6 -0
  71. data/lib/plurimath/math/function/font_style/sans-serif-bold-italic.rb +6 -0
  72. data/lib/plurimath/math/function/font_style/sans-serif-italic.rb +6 -0
  73. data/lib/plurimath/math/function/font_style/sans-serif.rb +6 -0
  74. data/lib/plurimath/math/function/font_style/script.rb +6 -0
  75. data/lib/plurimath/math/function/font_style.rb +4 -0
  76. data/lib/plurimath/math/function/frac.rb +6 -0
  77. data/lib/plurimath/math/function/gcd.rb +6 -0
  78. data/lib/plurimath/math/function/glb.rb +12 -0
  79. data/lib/plurimath/math/function/hat.rb +6 -0
  80. data/lib/plurimath/math/function/hom.rb +6 -0
  81. data/lib/plurimath/math/function/inf.rb +6 -0
  82. data/lib/plurimath/math/function/int.rb +7 -1
  83. data/lib/plurimath/math/function/intent.rb +35 -0
  84. data/lib/plurimath/math/function/ker.rb +6 -0
  85. data/lib/plurimath/math/function/lcm.rb +12 -0
  86. data/lib/plurimath/math/function/lg.rb +6 -0
  87. data/lib/plurimath/math/function/lim.rb +6 -0
  88. data/lib/plurimath/math/function/liminf.rb +6 -0
  89. data/lib/plurimath/math/function/limits.rb +6 -0
  90. data/lib/plurimath/math/function/limsup.rb +6 -0
  91. data/lib/plurimath/math/function/ln.rb +6 -0
  92. data/lib/plurimath/math/function/log.rb +6 -0
  93. data/lib/plurimath/math/function/longdiv.rb +6 -0
  94. data/lib/plurimath/math/function/lub.rb +6 -0
  95. data/lib/plurimath/math/function/max.rb +6 -0
  96. data/lib/plurimath/math/function/mbox.rb +6 -0
  97. data/lib/plurimath/math/function/menclose.rb +19 -0
  98. data/lib/plurimath/math/function/merror.rb +22 -0
  99. data/lib/plurimath/math/function/min.rb +6 -0
  100. data/lib/plurimath/math/function/mlabeledtr.rb +23 -0
  101. data/lib/plurimath/math/function/mod.rb +6 -0
  102. data/lib/plurimath/math/function/msgroup.rb +20 -0
  103. data/lib/plurimath/math/function/multiscript.rb +6 -0
  104. data/lib/plurimath/math/function/nary.rb +11 -0
  105. data/lib/plurimath/math/function/norm.rb +6 -0
  106. data/lib/plurimath/math/function/obrace.rb +6 -0
  107. data/lib/plurimath/math/function/oint.rb +7 -1
  108. data/lib/plurimath/math/function/over.rb +6 -0
  109. data/lib/plurimath/math/function/overleftrightarrow.rb +6 -0
  110. data/lib/plurimath/math/function/overset.rb +6 -0
  111. data/lib/plurimath/math/function/phantom.rb +6 -0
  112. data/lib/plurimath/math/function/power.rb +6 -0
  113. data/lib/plurimath/math/function/power_base.rb +6 -0
  114. data/lib/plurimath/math/function/prod.rb +7 -1
  115. data/lib/plurimath/math/function/right.rb +1 -1
  116. data/lib/plurimath/math/function/root.rb +6 -0
  117. data/lib/plurimath/math/function/rule.rb +20 -0
  118. data/lib/plurimath/math/function/scarries.rb +20 -0
  119. data/lib/plurimath/math/function/sec.rb +6 -0
  120. data/lib/plurimath/math/function/sech.rb +6 -0
  121. data/lib/plurimath/math/function/semantics.rb +20 -0
  122. data/lib/plurimath/math/function/sin.rb +6 -0
  123. data/lib/plurimath/math/function/sinh.rb +6 -0
  124. data/lib/plurimath/math/function/sqrt.rb +6 -0
  125. data/lib/plurimath/math/function/stackrel.rb +6 -0
  126. data/lib/plurimath/math/function/substack.rb +6 -0
  127. data/lib/plurimath/math/function/sum.rb +7 -1
  128. data/lib/plurimath/math/function/sup.rb +6 -0
  129. data/lib/plurimath/math/function/table/align.rb +6 -0
  130. data/lib/plurimath/math/function/table/array.rb +6 -0
  131. data/lib/plurimath/math/function/table/bmatrix.rb +6 -0
  132. data/lib/plurimath/math/function/table/cases.rb +6 -0
  133. data/lib/plurimath/math/function/table/eqarray.rb +6 -0
  134. data/lib/plurimath/math/function/table/matrix.rb +6 -0
  135. data/lib/plurimath/math/function/table/multline.rb +6 -0
  136. data/lib/plurimath/math/function/table/pmatrix.rb +6 -0
  137. data/lib/plurimath/math/function/table/split.rb +6 -0
  138. data/lib/plurimath/math/function/table/vmatrix.rb +6 -0
  139. data/lib/plurimath/math/function/table.rb +10 -0
  140. data/lib/plurimath/math/function/tan.rb +6 -0
  141. data/lib/plurimath/math/function/tanh.rb +6 -0
  142. data/lib/plurimath/math/function/ternary_function.rb +4 -0
  143. data/lib/plurimath/math/function/text.rb +6 -0
  144. data/lib/plurimath/math/function/tilde.rb +6 -0
  145. data/lib/plurimath/math/function/ubrace.rb +6 -0
  146. data/lib/plurimath/math/function/ul.rb +6 -0
  147. data/lib/plurimath/math/function/unary_function.rb +4 -0
  148. data/lib/plurimath/math/function/uncertainty.rb +655 -0
  149. data/lib/plurimath/math/function/underover.rb +6 -0
  150. data/lib/plurimath/math/function/underset.rb +6 -0
  151. data/lib/plurimath/math/function/vec.rb +6 -0
  152. data/lib/plurimath/math/function.rb +1 -0
  153. data/lib/plurimath/math/renderer.rb +95 -0
  154. data/lib/plurimath/math/semantic_tree.rb +331 -0
  155. data/lib/plurimath/math/symbols/symbol.rb +2 -0
  156. data/lib/plurimath/math.rb +26 -0
  157. data/lib/plurimath/mathml/translator.rb +32 -0
  158. data/lib/plurimath/mathml/uncertainty_marker.rb +118 -0
  159. data/lib/plurimath/omml/translator.rb +68 -0
  160. data/lib/plurimath/omml/utility.rb +9 -1
  161. data/lib/plurimath/symbol_documentation.rb +57 -0
  162. data/lib/plurimath/unicode_math/parsing_rules/common_rules.rb +2 -1
  163. data/lib/plurimath/unicode_math/parsing_rules/masked.rb +3 -0
  164. data/lib/plurimath/unicode_math/transform.rb +64 -3
  165. data/lib/plurimath/unicode_math/utility.rb +38 -0
  166. data/lib/plurimath/version.rb +1 -1
  167. data/lib/plurimath.rb +3 -0
  168. data/plurimath.gemspec +3 -1
  169. data/scripts/replay_asciimath_corpus.rb +611 -0
  170. metadata +30 -4
@@ -0,0 +1,611 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Replays the pinned plurimath-testsuite AsciiMath corpus against the
4
+ # Plurimath code loaded by this process, and fails unless every recorded
5
+ # outcome is reproduced byte for byte.
6
+ #
7
+ # Usage:
8
+ # bundle exec ruby scripts/replay_asciimath_corpus.rb \
9
+ # --testsuite-checkout DIR [--group NAME]... \
10
+ # [--expected-provenance-sha256 HEX]
11
+ #
12
+ # --testsuite-checkout DIR a plurimath-testsuite tree (git checkout or
13
+ # `git archive` extract) whose corpus/ is read.
14
+ # --group NAME replay only corpus/asciimath/NAME.yaml; may be
15
+ # repeated. Default: every AsciiMath payload.
16
+ # --expected-provenance-sha256 HEX
17
+ # SHA-256 the testsuite's corpus/provenance.yaml
18
+ # must have. Defaults to the pinned testsuite
19
+ # commit's file; override only for failure
20
+ # controls run against a deliberately edited copy.
21
+ #
22
+ # Exit status: 0 when every selected case reproduces; 1 when any case
23
+ # mismatches; 2 on a usage, provenance, integrity or payload-structure error
24
+ # (including an unknown schema, a missing expectation or zero selected cases).
25
+ #
26
+ # Before any expectation is used, corpus/provenance.yaml is checked against
27
+ # the pins below, and every payload it lists (all input formats, not only
28
+ # AsciiMath) is checked against its recorded SHA-256 and byte count.
29
+ #
30
+ # Dispatch is by each payload's `schema`; an unrecognized schema is an error,
31
+ # never a skip:
32
+ # plurimath-corpus/asciimath/1 `expected[target]` is the rendered string.
33
+ # plurimath-corpus/asciimath/2 `expected[target]` is {output:} or
34
+ # {error: {category: parse_error}}.
35
+ # plurimath-corpus/calls/1 as /2, rendered with the recorded
36
+ # `number_formatter` call.
37
+ # plurimath-corpus/calls/2 adds the `unitsml` call
38
+ # (`to_mathml(unitsml: {xml: true})`).
39
+ # plurimath-corpus/rejections/1 `Math.parse` must raise Math::ParseError and
40
+ # the grammar's deepest failure offset into the
41
+ # preprocessed text must equal `error.index`.
42
+ # Every case's `preprocessed` text is also checked. `parse_tree` and `model`
43
+ # are not replayed here: they are the A1 tree baseline's concern.
44
+
45
+ require "digest"
46
+ require "optparse"
47
+ require "yaml"
48
+ require "plurimath"
49
+
50
+ module AsciimathCorpusReplay
51
+ class Error < StandardError; end
52
+
53
+ # plurimath-testsuite e9e2403b2714219fa8ed80ad863460a9c5c7db0e
54
+ TESTSUITE_COMMIT = "e9e2403b2714219fa8ed80ad863460a9c5c7db0e"
55
+ PROVENANCE_SHA256 =
56
+ "4a212fd1e5cdb0223e19b573ed22dbadd28f8d6fd1ef730c678b1d449248019a"
57
+ PROVENANCE_SCHEMA = "plurimath-corpus/provenance/3"
58
+ GENERATOR_COMMIT = "53b9c5b5fc6e26fdf56c4d31e5882fbd631e41da"
59
+ GENERATOR_SHA256 =
60
+ "f2ff26da538f90070d44077af2f60f475eb69bc3ff64424a1096e7a0b8e1af66"
61
+ ORACLE_COMMIT = "ae20be111a26fdd988c58be0bfb2b7f6fe6e352f"
62
+ ORACLE_VERSION = "0.11.12"
63
+ XML_ENGINE = "Plurimath::XmlEngine::OxEngine"
64
+
65
+ INPUT_FORMAT = "asciimath"
66
+ TARGETS = %w[asciimath latex mathml unicodemath omml html].freeze
67
+ ERROR_CATEGORIES = %w[parse_error].freeze
68
+ NUMBER_FORMATTER_ARGS = %w[locale options precision string_format].freeze
69
+
70
+ CASE_KEYS = %w[id input input_format preprocessed expected parse_tree
71
+ model].freeze
72
+ CALL_CASE_KEYS = (CASE_KEYS + %w[call]).freeze
73
+ REJECTION_CASE_KEYS = %w[id input input_format preprocessed error].freeze
74
+ PAYLOAD_KEYS = %w[schema group description input_format targets
75
+ cases].freeze
76
+ REJECTION_PAYLOAD_KEYS = %w[schema group description input_format
77
+ cases].freeze
78
+
79
+ # schema => [payload keys, case keys, outcome shape, call methods allowed]
80
+ SCHEMAS = {
81
+ "plurimath-corpus/asciimath/1" => [PAYLOAD_KEYS, CASE_KEYS, :string, []],
82
+ "plurimath-corpus/asciimath/2" => [PAYLOAD_KEYS, CASE_KEYS, :outcome, []],
83
+ "plurimath-corpus/calls/1" =>
84
+ [PAYLOAD_KEYS, CALL_CASE_KEYS, :outcome, %w[number_formatter]],
85
+ "plurimath-corpus/calls/2" =>
86
+ [PAYLOAD_KEYS, CALL_CASE_KEYS, :outcome, %w[number_formatter unitsml]],
87
+ "plurimath-corpus/rejections/1" =>
88
+ [REJECTION_PAYLOAD_KEYS, REJECTION_CASE_KEYS, :rejection, []],
89
+ }.freeze
90
+
91
+ # Counts and failures of one run.
92
+ class Report
93
+ attr_reader :cases, :outcomes, :failures, :payloads
94
+
95
+ def initialize
96
+ @cases = Hash.new(0)
97
+ @outcomes = Hash.new { |h, k| h[k] = Hash.new(0) }
98
+ @failures = []
99
+ @payloads = Hash.new(0)
100
+ end
101
+
102
+ def outcome(schema, kind)
103
+ @outcomes[schema][kind] += 1
104
+ end
105
+
106
+ def fail!(schema, location, message)
107
+ @outcomes[schema]["mismatch"] += 1
108
+ @failures << "#{location}: #{message}"
109
+ end
110
+
111
+ def total_cases
112
+ @cases.values.sum
113
+ end
114
+
115
+ def lines
116
+ out = ["schema payloads cases checks"]
117
+ @cases.keys.sort.each do |schema|
118
+ checks = @outcomes[schema].sort.map { |k, v| "#{k}=#{v}" }.join(" ")
119
+ out << format("%<schema>-30s %<payloads>8d %<cases>5d %<checks>s",
120
+ schema: schema, payloads: @payloads[schema],
121
+ cases: @cases[schema], checks: checks)
122
+ end
123
+ out << "total: #{@payloads.values.sum} payloads, #{total_cases} cases, " \
124
+ "#{@failures.size} failures"
125
+ out
126
+ end
127
+ end
128
+
129
+ module_function
130
+
131
+ # --- provenance and integrity ---------------------------------------------
132
+
133
+ def sha256(bytes)
134
+ Digest::SHA256.hexdigest(bytes)
135
+ end
136
+
137
+ def load_yaml(path)
138
+ YAML.safe_load(File.binread(path).force_encoding(Encoding::UTF_8),
139
+ aliases: false)
140
+ rescue Psych::Exception => e
141
+ raise Error, "#{path}: not loadable YAML (#{e.class}: #{e.message})"
142
+ end
143
+
144
+ def check_equal!(label, actual, expected)
145
+ return if actual == expected
146
+
147
+ raise Error,
148
+ "provenance #{label} is #{actual.inspect}, expected #{expected.inspect}"
149
+ end
150
+
151
+ # Returns the provenance document after checking it against the pins, the
152
+ # running process and the bytes of every payload it lists.
153
+ def verify_provenance!(corpus_dir, expected_sha256)
154
+ path = File.join(corpus_dir, "provenance.yaml")
155
+ raise Error, "#{path} does not exist" unless File.file?(path)
156
+
157
+ actual_sha = sha256(File.binread(path))
158
+ unless actual_sha == expected_sha256
159
+ raise Error, "#{path} sha256 is #{actual_sha}, expected " \
160
+ "#{expected_sha256} (testsuite #{TESTSUITE_COMMIT})"
161
+ end
162
+
163
+ prov = load_yaml(path)
164
+ raise Error, "#{path}: not a mapping" unless prov.is_a?(Hash)
165
+
166
+ check_equal!("schema", prov["schema"], PROVENANCE_SCHEMA)
167
+ check_equal!("committable", prov["committable"], true)
168
+ check_equal!("warnings", prov["warnings"], [])
169
+ check_equal!("generator.repository.commit",
170
+ prov.dig("generator", "repository", "commit"), GENERATOR_COMMIT)
171
+ check_equal!("generator.repository.clean",
172
+ prov.dig("generator", "repository", "clean"), true)
173
+ check_equal!("generator.sha256", prov.dig("generator", "sha256"),
174
+ GENERATOR_SHA256)
175
+ check_equal!("oracle.gem", prov.dig("oracle", "gem"), "plurimath")
176
+ check_equal!("oracle.commit", prov.dig("oracle", "commit"), ORACLE_COMMIT)
177
+ check_equal!("oracle.version", prov.dig("oracle", "version"), ORACLE_VERSION)
178
+ check_equal!("oracle.clean", prov.dig("oracle", "clean"), true)
179
+ check_equal!("xml_engine", prov["xml_engine"], XML_ENGINE)
180
+ check_equal!("configuration", prov["configuration"], {})
181
+
182
+ verify_payloads!(corpus_dir, prov["payloads"])
183
+ prov
184
+ end
185
+
186
+ def verify_payloads!(corpus_dir, payloads)
187
+ unless payloads.is_a?(Array) && !payloads.empty?
188
+ raise Error, "provenance lists no payloads"
189
+ end
190
+
191
+ payloads.each do |entry|
192
+ rel = entry["path"]
193
+ if !rel.is_a?(String) || rel.start_with?("/") || rel.split("/").include?("..")
194
+ raise Error, "provenance payload path #{rel.inspect} is not a safe " \
195
+ "relative path"
196
+ end
197
+
198
+ file = File.join(corpus_dir, rel)
199
+ raise Error, "payload #{rel} listed in provenance is missing" unless File.file?(file)
200
+
201
+ bytes = File.binread(file)
202
+ unless bytes.bytesize == entry["bytes"]
203
+ raise Error, "payload #{rel} is #{bytes.bytesize} bytes, provenance " \
204
+ "records #{entry['bytes'].inspect}"
205
+ end
206
+ unless sha256(bytes) == entry["sha256"]
207
+ raise Error, "payload #{rel} sha256 #{sha256(bytes)} does not match " \
208
+ "provenance #{entry['sha256'].inspect}"
209
+ end
210
+ end
211
+
212
+ listed = payloads.map { |e| e["path"] }
213
+ on_disk = Dir.glob(File.join(corpus_dir, "*", "*.yaml"))
214
+ .map { |f| f.delete_prefix("#{corpus_dir}/") }
215
+ unlisted = on_disk - listed
216
+ return if unlisted.empty?
217
+
218
+ raise Error, "payloads not covered by provenance: #{unlisted.sort.join(', ')}"
219
+ end
220
+
221
+ # The process must match the conditions the corpus was generated under.
222
+ def verify_runtime!
223
+ engine = Plurimath.xml_engine.to_s
224
+ unless engine == XML_ENGINE
225
+ raise Error, "this process uses XML engine #{engine}; the corpus was " \
226
+ "generated with #{XML_ENGINE} (unset PLURIMATH_OGA)"
227
+ end
228
+
229
+ config = Plurimath.configuration
230
+ non_default = {
231
+ "locale" => config.locale,
232
+ "number_formatter" => config.number_formatter,
233
+ "decimal" => (config.decimal unless config.decimal ==
234
+ Plurimath::Configuration::DEFAULT_DECIMAL),
235
+ "evaluation_max_iterations" =>
236
+ (config.evaluation_max_iterations unless
237
+ config.evaluation_max_iterations ==
238
+ Plurimath::Configuration::DEFAULT_MAX_ITERATIONS),
239
+ }.compact
240
+ return if non_default.empty?
241
+
242
+ raise Error, "Plurimath configuration is not the default the corpus " \
243
+ "was generated with: #{non_default.inspect}"
244
+ end
245
+
246
+ # --- selection ---------------------------------------------------------------
247
+
248
+ def select_payloads(corpus_dir, provenance, groups)
249
+ paths = provenance["payloads"].map { |e| e["path"] }
250
+ .select { |p| p.start_with?("#{INPUT_FORMAT}/") }.sort
251
+ available = paths.map { |p| File.basename(p, ".yaml") }
252
+ unless groups.empty?
253
+ unknown = groups - available
254
+ raise Error, "unknown --group #{unknown.join(', ')}" unless unknown.empty?
255
+
256
+ paths = paths.select { |p| groups.include?(File.basename(p, ".yaml")) }
257
+ end
258
+ raise Error, "zero AsciiMath payloads selected" if paths.empty?
259
+
260
+ paths.map { |p| File.join(corpus_dir, p) }
261
+ end
262
+
263
+ # --- replay -------------------------------------------------------------------
264
+
265
+ def replay_file(path, report)
266
+ payload = load_yaml(path)
267
+ replay_payload(payload, File.basename(path, ".yaml"), report)
268
+ end
269
+
270
+ # Replays one parsed payload. Structural problems raise Error; a behavior
271
+ # difference is recorded in the report as a failure.
272
+ def replay_payload(payload, group, report)
273
+ raise Error, "#{group}: payload is not a mapping" unless payload.is_a?(Hash)
274
+
275
+ schema = payload["schema"]
276
+ spec = SCHEMAS[schema]
277
+ raise Error, "#{group}: unknown schema #{schema.inspect}" unless spec
278
+
279
+ payload_keys, case_keys, shape, call_methods = spec
280
+ check_keys!(payload, payload_keys, "#{group}: payload")
281
+ unless payload["group"] == group
282
+ raise Error, "#{group}: payload group is #{payload['group'].inspect}"
283
+ end
284
+ unless payload["input_format"] == INPUT_FORMAT
285
+ raise Error, "#{group}: input_format is #{payload['input_format'].inspect}"
286
+ end
287
+
288
+ targets = payload_targets(payload, shape, group)
289
+ cases = payload["cases"]
290
+ unless cases.is_a?(Array) && !cases.empty?
291
+ raise Error, "#{group}: payload has no cases"
292
+ end
293
+
294
+ ids = cases.map { |c| c.is_a?(Hash) ? c["id"] : nil }
295
+ dupes = ids.tally.select { |_, n| n > 1 }.keys
296
+ raise Error, "#{group}: duplicate case ids #{dupes.inspect}" unless dupes.empty?
297
+
298
+ report.payloads[schema] += 1
299
+ cases.each do |kase|
300
+ raise Error, "#{group}: case is not a mapping" unless kase.is_a?(Hash)
301
+
302
+ location = "#{group}/#{kase['id']}"
303
+ check_keys!(kase, case_keys, location)
304
+ unless kase["input"].is_a?(String) && kase["input_format"] == INPUT_FORMAT
305
+ raise Error, "#{location}: input must be a String with input_format " \
306
+ "#{INPUT_FORMAT}"
307
+ end
308
+
309
+ report.cases[schema] += 1
310
+ check_preprocessed(kase, schema, location, report)
311
+ if shape == :rejection
312
+ replay_rejection(kase, schema, location, report)
313
+ else
314
+ layout = { shape: shape, targets: targets, call_methods: call_methods }
315
+ replay_case(kase, schema, layout, location, report)
316
+ end
317
+ end
318
+ end
319
+
320
+ def check_keys!(hash, allowed, location)
321
+ missing = allowed - hash.keys
322
+ extra = hash.keys - allowed
323
+ return if missing.empty? && extra.empty?
324
+
325
+ raise Error, "#{location}: missing keys #{missing.inspect}, unexpected " \
326
+ "keys #{extra.inspect}"
327
+ end
328
+
329
+ def payload_targets(payload, shape, group)
330
+ return [] if shape == :rejection
331
+
332
+ targets = payload["targets"]
333
+ unless targets.is_a?(Array) && !targets.empty? && targets.uniq == targets
334
+ raise Error, "#{group}: targets must be a non-empty list without repeats"
335
+ end
336
+
337
+ unknown = targets - TARGETS
338
+ raise Error, "#{group}: unknown targets #{unknown.inspect}" unless unknown.empty?
339
+
340
+ targets
341
+ end
342
+
343
+ def check_preprocessed(kase, schema, location, report)
344
+ actual = Plurimath::Asciimath::Parser.new(kase["input"]).text
345
+ if actual == kase["preprocessed"]
346
+ report.outcome(schema, "preprocessed_match")
347
+ else
348
+ report.fail!(schema, location, "preprocessed #{actual.inspect}, " \
349
+ "expected #{kase['preprocessed'].inspect}")
350
+ end
351
+ end
352
+
353
+ def replay_case(kase, schema, layout, location, report)
354
+ shape, targets, call_methods = layout.values_at(:shape, :targets, :call_methods)
355
+ expected = kase["expected"]
356
+ raise Error, "#{location}: expected is not a mapping" unless expected.is_a?(Hash)
357
+
358
+ missing = targets - expected.keys
359
+ extra = expected.keys - targets
360
+ unless missing.empty? && extra.empty?
361
+ raise Error, "#{location}: expectations missing for #{missing.inspect}, " \
362
+ "undeclared targets #{extra.inspect}"
363
+ end
364
+
365
+ call = build_call(kase["call"], call_methods, location)
366
+ formula = parse(kase["input"], schema, location, report)
367
+ return unless formula
368
+
369
+ targets.each do |target|
370
+ want = normalize_outcome(expected[target], shape, "#{location}.#{target}")
371
+ got = render(formula, target, call)
372
+ compare(schema, "#{location}.#{target}", want, got, report)
373
+ end
374
+ end
375
+
376
+ def parse(input, schema, location, report)
377
+ Plurimath::Math.parse(input, :asciimath)
378
+ rescue StandardError => e
379
+ report.fail!(schema, location, "parse raised #{e.class}: #{e.message}")
380
+ nil
381
+ end
382
+
383
+ # Returns [:output, String] or [:error, category].
384
+ def normalize_outcome(value, shape, location)
385
+ if shape == :string
386
+ raise Error, "#{location}: expectation is not a String" unless value.is_a?(String)
387
+
388
+ return [:output, value]
389
+ end
390
+
391
+ unless value.is_a?(Hash) && value.size == 1
392
+ raise Error, "#{location}: outcome must have exactly one of output/error"
393
+ end
394
+
395
+ if value.key?("output")
396
+ raise Error, "#{location}: output is not a String" unless value["output"].is_a?(String)
397
+
398
+ [:output, value["output"]]
399
+ elsif value.key?("error")
400
+ error = value["error"]
401
+ unless error.is_a?(Hash) && error.keys == ["category"] &&
402
+ ERROR_CATEGORIES.include?(error["category"])
403
+ raise Error, "#{location}: unknown error outcome #{error.inspect}"
404
+ end
405
+
406
+ [:error, error["category"]]
407
+ else
408
+ raise Error, "#{location}: outcome has neither output nor error"
409
+ end
410
+ end
411
+
412
+ # Returns a lambda rendering one target, or nil for a plain render.
413
+ def build_call(call, call_methods, location)
414
+ if call_methods.empty?
415
+ raise Error, "#{location}: unexpected call" unless call.nil?
416
+
417
+ return nil
418
+ end
419
+
420
+ unless call.is_a?(Hash) && call.keys.sort == %w[args method]
421
+ raise Error, "#{location}: call must have exactly method and args"
422
+ end
423
+
424
+ method = call["method"]
425
+ args = call["args"]
426
+ unless call_methods.include?(method)
427
+ raise Error, "#{location}: unknown call method #{method.inspect}"
428
+ end
429
+
430
+ case method
431
+ when "number_formatter" then number_formatter_call(args, location)
432
+ when "unitsml" then unitsml_call(args, location)
433
+ end
434
+ end
435
+
436
+ # `call.args` are Formatter::Standard.new's keyword arguments. YAML carries
437
+ # option keys as strings; the formatter reads them as symbols. Enumerated
438
+ # option values recorded from Ruby symbols are coerced by the formatter
439
+ # itself (FormatOptions#symbol_option), so values are passed as recorded.
440
+ def number_formatter_call(args, location)
441
+ unless args.is_a?(Hash) && args.keys.sort == NUMBER_FORMATTER_ARGS
442
+ raise Error, "#{location}: number_formatter args must be exactly " \
443
+ "#{NUMBER_FORMATTER_ARGS.inspect}, got #{args.inspect}"
444
+ end
445
+ raise Error, "#{location}: options is not a mapping" unless args["options"].is_a?(Hash)
446
+
447
+ kwargs = {
448
+ locale: args["locale"],
449
+ string_format: args["string_format"],
450
+ precision: args["precision"],
451
+ options: args["options"].transform_keys(&:to_sym),
452
+ }
453
+ formatter = Plurimath::Formatter::Standard.new(**kwargs)
454
+ { kind: "number_formatter",
455
+ render: ->(formula, target) { formula.public_send("to_#{target}", formatter: formatter) } }
456
+ end
457
+
458
+ def unitsml_call(args, location)
459
+ unless args == { "xml" => true }
460
+ raise Error, "#{location}: unitsml args #{args.inspect} are not the " \
461
+ "recorded {xml: true}"
462
+ end
463
+
464
+ { kind: "unitsml",
465
+ render: lambda { |formula, target|
466
+ unless target == "mathml"
467
+ raise Error, "#{location}: unitsml call declared for #{target}"
468
+ end
469
+
470
+ formula.to_mathml(unitsml: { xml: true })
471
+ } }
472
+ end
473
+
474
+ # Returns [:output, String], [:error, "parse_error"] or [:raised, message].
475
+ def render(formula, target, call)
476
+ output = if call
477
+ call[:render].call(formula, target)
478
+ else
479
+ formula.public_send("to_#{target}")
480
+ end
481
+ [:output, output]
482
+ rescue Error
483
+ raise
484
+ rescue Plurimath::Math::ParseError
485
+ [:error, "parse_error"]
486
+ rescue StandardError => e
487
+ [:raised, "#{e.class}: #{e.message}"]
488
+ end
489
+
490
+ def compare(schema, location, want, got, report)
491
+ if want == got
492
+ report.outcome(schema, want.first == :output ? "output_match" : "render_error_match")
493
+ return
494
+ end
495
+
496
+ report.fail!(schema, location, describe_mismatch(want, got))
497
+ end
498
+
499
+ def describe_mismatch(want, got)
500
+ if want.first == :output && got.first == :output
501
+ a = want.last
502
+ b = got.last
503
+ at = (0...[a.size, b.size].min).find { |i| a[i] != b[i] } || [a.size, b.size].min
504
+ "output differs at character #{at}: expected " \
505
+ "#{a[at, 40].inspect}, got #{b[at, 40].inspect}"
506
+ else
507
+ "expected #{want.inspect[0, 120]}, got #{got.inspect[0, 120]}"
508
+ end
509
+ end
510
+
511
+ def replay_rejection(kase, schema, location, report)
512
+ error = kase["error"]
513
+ unless error.is_a?(Hash) && ERROR_CATEGORIES.include?(error["category"]) &&
514
+ (error.keys - %w[category index]).empty? &&
515
+ (!error.key?("index") || error["index"].is_a?(Integer))
516
+ raise Error, "#{location}: unknown rejection error #{error.inspect}"
517
+ end
518
+
519
+ begin
520
+ Plurimath::Math.parse(kase["input"], :asciimath)
521
+ report.fail!(schema, location, "input was ACCEPTED; expected parse_error")
522
+ return
523
+ rescue Plurimath::Math::ParseError
524
+ report.outcome(schema, "rejection_match")
525
+ rescue StandardError => e
526
+ report.fail!(schema, location, "raised #{e.class} instead of " \
527
+ "Plurimath::Math::ParseError")
528
+ return
529
+ end
530
+
531
+ actual_index = grammar_failure_index(kase["preprocessed"])
532
+ if actual_index == error["index"]
533
+ report.outcome(schema, error.key?("index") ? "rejection_index_match" : "rejection_no_index_match")
534
+ else
535
+ report.fail!(schema, location, "failure index #{actual_index.inspect}, " \
536
+ "expected #{error['index'].inspect}")
537
+ end
538
+ end
539
+
540
+ # The grammar's deepest failure offset (in characters) in the preprocessed
541
+ # text, as amath reports it; nil when the grammar does not fail.
542
+ def grammar_failure_index(preprocessed)
543
+ require "amath"
544
+
545
+ ::Amath.parse(preprocessed, decimal: Plurimath.configuration.decimal)
546
+ nil
547
+ rescue ::Amath::ParseError => e
548
+ e.char_offset
549
+ end
550
+
551
+ # --- CLI ------------------------------------------------------------------
552
+
553
+ def parse_options(argv)
554
+ options = { groups: [], provenance_sha256: PROVENANCE_SHA256 }
555
+ parser = OptionParser.new do |o|
556
+ o.banner = "Usage: ruby scripts/replay_asciimath_corpus.rb " \
557
+ "--testsuite-checkout DIR [--group NAME]... " \
558
+ "[--expected-provenance-sha256 HEX]"
559
+ o.on("--testsuite-checkout DIR") { |v| options[:checkout] = v }
560
+ o.on("--group NAME") { |v| options[:groups] << v }
561
+ o.on("--expected-provenance-sha256 HEX") { |v| options[:provenance_sha256] = v }
562
+ o.on("-h", "--help") { options[:help] = o.help }
563
+ end
564
+ parser.parse!(argv.dup)
565
+ options[:usage] = parser.help
566
+ options
567
+ rescue OptionParser::ParseError => e
568
+ raise Error, e.message
569
+ end
570
+
571
+ def run(argv, out: $stdout)
572
+ options = parse_options(argv)
573
+ if options[:help]
574
+ out.puts options[:help]
575
+ return 0
576
+ end
577
+ raise Error, "--testsuite-checkout is required\n#{options[:usage]}" unless options[:checkout]
578
+
579
+ corpus_dir = File.join(File.expand_path(options[:checkout]), "corpus")
580
+ provenance = verify_provenance!(corpus_dir, options[:provenance_sha256])
581
+ verify_runtime!
582
+ out.puts "testsuite: #{corpus_dir} (provenance sha256 " \
583
+ "#{options[:provenance_sha256]}; pinned commit #{TESTSUITE_COMMIT})"
584
+ out.puts "oracle: plurimath #{ORACLE_VERSION} @ #{ORACLE_COMMIT}; " \
585
+ "#{provenance['payloads'].size} payload checksums verified"
586
+ out.puts "running: plurimath #{Plurimath::VERSION}, ruby #{RUBY_VERSION}, " \
587
+ "#{Plurimath.xml_engine}"
588
+ if Plurimath::VERSION != ORACLE_VERSION
589
+ out.puts "note: running Plurimath version differs from the oracle's"
590
+ end
591
+
592
+ report = Report.new
593
+ select_payloads(corpus_dir, provenance, options[:groups]).each do |path|
594
+ replay_file(path, report)
595
+ end
596
+ raise Error, "zero cases selected" if report.total_cases.zero?
597
+
598
+ report.lines.each { |line| out.puts line }
599
+ report.failures.each { |failure| out.puts "MISMATCH #{failure}" }
600
+ report.failures.empty? ? 0 : 1
601
+ end
602
+ end
603
+
604
+ if $PROGRAM_NAME == __FILE__
605
+ begin
606
+ exit AsciimathCorpusReplay.run(ARGV)
607
+ rescue AsciimathCorpusReplay::Error => e
608
+ warn "replay_asciimath_corpus: #{e.message}"
609
+ exit 2
610
+ end
611
+ end