plurimath 0.11.11 → 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.
- checksums.yaml +4 -4
- data/.github/workflows/dependent-tests.yml +7 -0
- data/.github/workflows/shared-testsuite.yml +24 -0
- data/.gitmodules +3 -0
- data/.rspec-opal +3 -0
- data/Gemfile +18 -0
- data/README.adoc +297 -3
- data/docs/catalog.adoc +73 -0
- data/docs/uncertainty-asciimath-tree.md +92 -0
- data/docs/uncertainty.adoc +522 -0
- data/lib/plurimath/asciimath/parse.rb +43 -226
- data/lib/plurimath/asciimath/parser.rb +58 -1
- data/lib/plurimath/asciimath/slice_adapter.rb +138 -0
- data/lib/plurimath/asciimath/transform.rb +218 -0
- data/lib/plurimath/asciimath.rb +1 -0
- data/lib/plurimath/catalog/entry.rb +109 -0
- data/lib/plurimath/catalog/validation.rb +109 -0
- data/lib/plurimath/catalog.rb +96 -0
- data/lib/plurimath/cli/convert.rb +40 -0
- data/lib/plurimath/cli/helpers.rb +49 -0
- data/lib/plurimath/cli/render.rb +64 -0
- data/lib/plurimath/cli.rb +55 -57
- data/lib/plurimath/documentation.rb +99 -0
- data/lib/plurimath/errors/ambiguous_decimal_error.rb +19 -0
- data/lib/plurimath/errors/invalid_uncertainty.rb +18 -0
- data/lib/plurimath/errors/missing_dependency_error.rb +23 -0
- data/lib/plurimath/errors/rendering_failed.rb +17 -0
- data/lib/plurimath/errors/rendering_unavailable.rb +25 -0
- data/lib/plurimath/errors/unsupported_render_format.rb +18 -0
- data/lib/plurimath/errors/unsupported_semantic_tree_error.rb +12 -0
- data/lib/plurimath/errors.rb +5 -0
- data/lib/plurimath/math/evaluation/evaluator.rb +48 -0
- data/lib/plurimath/math/evaluation/expression_parser.rb +29 -20
- data/lib/plurimath/math/formula/class_tree.rb +92 -0
- data/lib/plurimath/math/formula.rb +43 -2
- data/lib/plurimath/math/function/abs.rb +6 -0
- data/lib/plurimath/math/function/arccos.rb +6 -0
- data/lib/plurimath/math/function/arcsin.rb +6 -0
- data/lib/plurimath/math/function/arctan.rb +6 -0
- data/lib/plurimath/math/function/arg.rb +20 -0
- data/lib/plurimath/math/function/bar.rb +6 -0
- data/lib/plurimath/math/function/base.rb +6 -0
- data/lib/plurimath/math/function/binary_function.rb +4 -0
- data/lib/plurimath/math/function/cancel.rb +6 -0
- data/lib/plurimath/math/function/ceil.rb +6 -0
- data/lib/plurimath/math/function/color.rb +12 -0
- data/lib/plurimath/math/function/cos.rb +6 -0
- data/lib/plurimath/math/function/cosh.rb +6 -0
- data/lib/plurimath/math/function/cot.rb +6 -0
- data/lib/plurimath/math/function/coth.rb +6 -0
- data/lib/plurimath/math/function/csc.rb +6 -0
- data/lib/plurimath/math/function/csch.rb +6 -0
- data/lib/plurimath/math/function/ddot.rb +6 -0
- data/lib/plurimath/math/function/deg.rb +6 -0
- data/lib/plurimath/math/function/det.rb +6 -0
- data/lib/plurimath/math/function/dim.rb +6 -0
- data/lib/plurimath/math/function/dot.rb +6 -0
- data/lib/plurimath/math/function/exp.rb +6 -0
- data/lib/plurimath/math/function/fenced.rb +6 -0
- data/lib/plurimath/math/function/floor.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold-fraktur.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold-italic.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold-sans-serif.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold-script.rb +6 -0
- data/lib/plurimath/math/function/font_style/bold.rb +6 -0
- data/lib/plurimath/math/function/font_style/double_struck.rb +6 -0
- data/lib/plurimath/math/function/font_style/fraktur.rb +6 -0
- data/lib/plurimath/math/function/font_style/italic.rb +6 -0
- data/lib/plurimath/math/function/font_style/monospace.rb +6 -0
- data/lib/plurimath/math/function/font_style/normal.rb +6 -0
- data/lib/plurimath/math/function/font_style/sans-serif-bold-italic.rb +6 -0
- data/lib/plurimath/math/function/font_style/sans-serif-italic.rb +6 -0
- data/lib/plurimath/math/function/font_style/sans-serif.rb +6 -0
- data/lib/plurimath/math/function/font_style/script.rb +6 -0
- data/lib/plurimath/math/function/font_style.rb +4 -0
- data/lib/plurimath/math/function/frac.rb +6 -0
- data/lib/plurimath/math/function/gcd.rb +6 -0
- data/lib/plurimath/math/function/glb.rb +12 -0
- data/lib/plurimath/math/function/hat.rb +6 -0
- data/lib/plurimath/math/function/hom.rb +6 -0
- data/lib/plurimath/math/function/inf.rb +6 -0
- data/lib/plurimath/math/function/int.rb +7 -1
- data/lib/plurimath/math/function/intent.rb +35 -0
- data/lib/plurimath/math/function/ker.rb +6 -0
- data/lib/plurimath/math/function/lcm.rb +12 -0
- data/lib/plurimath/math/function/lg.rb +6 -0
- data/lib/plurimath/math/function/lim.rb +6 -0
- data/lib/plurimath/math/function/liminf.rb +6 -0
- data/lib/plurimath/math/function/limits.rb +6 -0
- data/lib/plurimath/math/function/limsup.rb +6 -0
- data/lib/plurimath/math/function/ln.rb +6 -0
- data/lib/plurimath/math/function/log.rb +6 -0
- data/lib/plurimath/math/function/longdiv.rb +6 -0
- data/lib/plurimath/math/function/lub.rb +6 -0
- data/lib/plurimath/math/function/max.rb +6 -0
- data/lib/plurimath/math/function/mbox.rb +6 -0
- data/lib/plurimath/math/function/menclose.rb +19 -0
- data/lib/plurimath/math/function/merror.rb +22 -0
- data/lib/plurimath/math/function/min.rb +6 -0
- data/lib/plurimath/math/function/mlabeledtr.rb +23 -0
- data/lib/plurimath/math/function/mod.rb +6 -0
- data/lib/plurimath/math/function/msgroup.rb +20 -0
- data/lib/plurimath/math/function/multiscript.rb +6 -0
- data/lib/plurimath/math/function/nary.rb +11 -0
- data/lib/plurimath/math/function/norm.rb +6 -0
- data/lib/plurimath/math/function/obrace.rb +6 -0
- data/lib/plurimath/math/function/oint.rb +7 -1
- data/lib/plurimath/math/function/over.rb +6 -0
- data/lib/plurimath/math/function/overleftrightarrow.rb +6 -0
- data/lib/plurimath/math/function/overset.rb +6 -0
- data/lib/plurimath/math/function/phantom.rb +6 -0
- data/lib/plurimath/math/function/power.rb +6 -0
- data/lib/plurimath/math/function/power_base.rb +6 -0
- data/lib/plurimath/math/function/prod.rb +7 -1
- data/lib/plurimath/math/function/right.rb +1 -1
- data/lib/plurimath/math/function/root.rb +6 -0
- data/lib/plurimath/math/function/rule.rb +20 -0
- data/lib/plurimath/math/function/scarries.rb +20 -0
- data/lib/plurimath/math/function/sec.rb +6 -0
- data/lib/plurimath/math/function/sech.rb +6 -0
- data/lib/plurimath/math/function/semantics.rb +20 -0
- data/lib/plurimath/math/function/sin.rb +6 -0
- data/lib/plurimath/math/function/sinh.rb +6 -0
- data/lib/plurimath/math/function/sqrt.rb +6 -0
- data/lib/plurimath/math/function/stackrel.rb +6 -0
- data/lib/plurimath/math/function/substack.rb +6 -0
- data/lib/plurimath/math/function/sum.rb +7 -1
- data/lib/plurimath/math/function/sup.rb +6 -0
- data/lib/plurimath/math/function/table/align.rb +6 -0
- data/lib/plurimath/math/function/table/array.rb +6 -0
- data/lib/plurimath/math/function/table/bmatrix.rb +6 -0
- data/lib/plurimath/math/function/table/cases.rb +6 -0
- data/lib/plurimath/math/function/table/eqarray.rb +6 -0
- data/lib/plurimath/math/function/table/matrix.rb +6 -0
- data/lib/plurimath/math/function/table/multline.rb +6 -0
- data/lib/plurimath/math/function/table/pmatrix.rb +6 -0
- data/lib/plurimath/math/function/table/split.rb +6 -0
- data/lib/plurimath/math/function/table/vmatrix.rb +6 -0
- data/lib/plurimath/math/function/table.rb +10 -0
- data/lib/plurimath/math/function/tan.rb +6 -0
- data/lib/plurimath/math/function/tanh.rb +6 -0
- data/lib/plurimath/math/function/ternary_function.rb +4 -0
- data/lib/plurimath/math/function/text.rb +6 -0
- data/lib/plurimath/math/function/tilde.rb +6 -0
- data/lib/plurimath/math/function/ubrace.rb +6 -0
- data/lib/plurimath/math/function/ul.rb +6 -0
- data/lib/plurimath/math/function/unary_function.rb +4 -0
- data/lib/plurimath/math/function/uncertainty.rb +655 -0
- data/lib/plurimath/math/function/underover.rb +6 -0
- data/lib/plurimath/math/function/underset.rb +6 -0
- data/lib/plurimath/math/function/vec.rb +6 -0
- data/lib/plurimath/math/function.rb +1 -0
- data/lib/plurimath/math/renderer.rb +95 -0
- data/lib/plurimath/math/semantic_tree.rb +331 -0
- data/lib/plurimath/math/symbols/symbol.rb +12 -5
- data/lib/plurimath/math.rb +26 -0
- data/lib/plurimath/mathml/translator.rb +32 -0
- data/lib/plurimath/mathml/uncertainty_marker.rb +118 -0
- data/lib/plurimath/omml/translator.rb +68 -0
- data/lib/plurimath/omml/utility.rb +9 -1
- data/lib/plurimath/symbol_documentation.rb +57 -0
- data/lib/plurimath/unicode_math/parsing_rules/common_rules.rb +2 -1
- data/lib/plurimath/unicode_math/parsing_rules/masked.rb +3 -0
- data/lib/plurimath/unicode_math/transform.rb +64 -3
- data/lib/plurimath/unicode_math/utility.rb +38 -0
- data/lib/plurimath/version.rb +1 -1
- data/lib/plurimath.rb +3 -0
- data/plurimath.gemspec +3 -1
- data/scripts/replay_asciimath_corpus.rb +611 -0
- 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
|