umath 0.1.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE.md +33 -0
- data/README.adoc +155 -0
- data/artifacts/umath.bindings.ruby.json +1 -0
- data/artifacts/umath.json +1 -0
- data/data/umath-symbols.json +13145 -0
- data/docs/model-contract.adoc +184 -0
- data/docs/parser-contract.adoc +341 -0
- data/docs/testsuite-pin.adoc +95 -0
- data/lib/umath/artifact.rb +84 -0
- data/lib/umath/configuration.rb +32 -0
- data/lib/umath/engine.rb +163 -0
- data/lib/umath/errors.rb +38 -0
- data/lib/umath/model/nodes.rb +737 -0
- data/lib/umath/model.rb +30 -0
- data/lib/umath/native_recursion_guard.rb +43 -0
- data/lib/umath/parser.rb +50 -0
- data/lib/umath/preprocessor.rb +91 -0
- data/lib/umath/raw_parser.rb +59 -0
- data/lib/umath/source_map.rb +146 -0
- data/lib/umath/symbols.rb +71 -0
- data/lib/umath/version.rb +5 -0
- data/lib/umath.rb +73 -0
- metadata +110 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
= UnicodeMath model contract
|
|
2
|
+
:toc:
|
|
3
|
+
|
|
4
|
+
== Scope
|
|
5
|
+
|
|
6
|
+
`Umath.parse(input, decimal: ".", engine: :auto)` returns a
|
|
7
|
+
`Umath::Model::Document`, the typed UnicodeMath language model. This
|
|
8
|
+
document describes that model: how it is built from the grammar, what its
|
|
9
|
+
fields hold, and how it is checked. The rules shared by all stage gems
|
|
10
|
+
(API shape, errors, engines, artifacts, corpus pins) are in the stage
|
|
11
|
+
profile, amath's
|
|
12
|
+
https://github.com/plurimath/amath/blob/main/docs/stage-contract.adoc[`docs/stage-contract.adoc`];
|
|
13
|
+
this document only adds what is specific to UnicodeMath. The grammar,
|
|
14
|
+
preprocessing, the capture-shape normalization and the raw tree are
|
|
15
|
+
described in link:parser-contract.adoc[the parser contract].
|
|
16
|
+
|
|
17
|
+
The model is the UnicodeMath model, not a shared one. Mapping it to
|
|
18
|
+
Plurimath's `Formula` classes is Plurimath's job.
|
|
19
|
+
|
|
20
|
+
== Construction
|
|
21
|
+
|
|
22
|
+
The grammar is normalized so that every capture always holds one kind of
|
|
23
|
+
value: text, a node (hash) or a list (see the parser contract). The grammar
|
|
24
|
+
binds its one root capture twice. The `raw` binding keeps the tree with its
|
|
25
|
+
slices and offsets, for parity replay and error positions. The `any`
|
|
26
|
+
binding (path `model`) gives the same tree as plain hashes, arrays and
|
|
27
|
+
strings. `Model::Document.new` receives that tree, and lutaml-model builds
|
|
28
|
+
every nested node from the declared attribute types. No gem code walks the
|
|
29
|
+
parse tree to build the model, and no class has a cast hook.
|
|
30
|
+
|
|
31
|
+
After construction, one separate pass resolves symbols (see below).
|
|
32
|
+
|
|
33
|
+
=== Classes and fields
|
|
34
|
+
|
|
35
|
+
`scripts/generate_model.rb` writes the classes in
|
|
36
|
+
`lib/umath/model/nodes.rb` from the normalized rules
|
|
37
|
+
(`grammars/umath.normalized-rules.json`). The mapping is mechanical,
|
|
38
|
+
because every capture holds one kind:
|
|
39
|
+
|
|
40
|
+
* A capture that holds a node becomes a class named after it, for example
|
|
41
|
+
`frac` becomes `Model::Frac`. Three names are changed so they do not
|
|
42
|
+
shadow Ruby or model classes: `array` becomes `ArrayNode`, `symbol`
|
|
43
|
+
becomes `SymbolNode` and `root` becomes `RootNode`.
|
|
44
|
+
* A capture that holds a list becomes a collection attribute of the class
|
|
45
|
+
`<Capture>Item`, for example `prime_accent_symbols` holds
|
|
46
|
+
`PrimeAccentSymbolsItem` elements.
|
|
47
|
+
* A capture that holds text becomes a String attribute.
|
|
48
|
+
* Each class declares one attribute for every capture that can appear
|
|
49
|
+
directly inside it. An attribute has the same name and the same type in
|
|
50
|
+
every class that declares it, because its type is the type of the
|
|
51
|
+
capture it names.
|
|
52
|
+
* Captures that hold a whole expression (25 or more possible inner
|
|
53
|
+
captures, such as `expr`, `exp`, `factor`, `numerator`, `base`, `sub`,
|
|
54
|
+
`sup`, `first_value` and the root) share one class, `Model::Expression`,
|
|
55
|
+
which declares the union of their inner captures. The mini-script
|
|
56
|
+
captures (`mini_expr`, `sub_recursions` and the like) share
|
|
57
|
+
`Model::MiniExpression`, and `open_paren` and `close_paren` share
|
|
58
|
+
`Model::Paren`. lutaml-model compiles a serialization transformation for
|
|
59
|
+
every path through the class graph, and with one class per expression
|
|
60
|
+
capture that number grows past any practical time. The generator fails if
|
|
61
|
+
any other cycle of classes is left outside `Expression`.
|
|
62
|
+
* The captures added by the normalization appear as ordinary fields:
|
|
63
|
+
`literal` (text that legacy folded into its parent), `empty` (an absent
|
|
64
|
+
optional part, legacy `""`), `list` (a list in a node slot) and `present`
|
|
65
|
+
(a lookahead that matched).
|
|
66
|
+
|
|
67
|
+
These rules give 74 classes plus `Document`. The exported declarations,
|
|
68
|
+
with every field and its type, are in `model/umath.lml.json`.
|
|
69
|
+
|
|
70
|
+
Text fields keep the text the grammar matched. The grammar runs on the
|
|
71
|
+
preprocessed, entity-encoded input, so non-ASCII text appears as character
|
|
72
|
+
references: a `text` capture for `"αβ"` holds `αβ`. This is the
|
|
73
|
+
same text the legacy transform received. The `value` of a symbol gives its
|
|
74
|
+
Unicode text.
|
|
75
|
+
|
|
76
|
+
=== Symbols
|
|
77
|
+
|
|
78
|
+
The symbol captures are the captures whose legacy transform rules build a
|
|
79
|
+
symbol through Plurimath's symbol catalog (`alphanumeric`, `operator`,
|
|
80
|
+
`ordinary_symbols`, `relational_symbols`, `nary_class`, `unicode_symbols`,
|
|
81
|
+
`accent_symbols`, the paren prefixes and others; the list is in
|
|
82
|
+
`scripts/generate_grammar.rb`). The grammar captures their text as
|
|
83
|
+
`spelling`, so each holds a `Model::MathSymbol` with `spelling`, `value`
|
|
84
|
+
and `kind`. The symbol captures that hold a node (`symbol`, `open_paren`,
|
|
85
|
+
`close_paren`, `prime_accent_symbols`) capture their own text the same way,
|
|
86
|
+
and their classes (`SymbolNode`, `Paren`, `PrimeAccentSymbolsItem`) also
|
|
87
|
+
declare `spelling`, `value` and `kind`. For example, the base of `x^2` is
|
|
88
|
+
a `SymbolNode` with spelling `x`.
|
|
89
|
+
|
|
90
|
+
=== Document
|
|
91
|
+
|
|
92
|
+
`expression`:: The formula's tree, an `Expression`. Nil for input without
|
|
93
|
+
a formula: an empty string, or a string made only of `#` label
|
|
94
|
+
separators. The legacy facade fails on such input with `NoMethodError`, so
|
|
95
|
+
it has no defined result to keep; like amath, the gem returns an empty
|
|
96
|
+
result. An empty formula before a label, such as `#b`, reaches the grammar
|
|
97
|
+
and is refused there.
|
|
98
|
+
`label`:: The equation label: the text after the last `#`, or nil when the
|
|
99
|
+
input has none. This follows the legacy facade. A `#` inside `✎(...)` does
|
|
100
|
+
not start a label.
|
|
101
|
+
`decimal`:: The decimal marker the input was parsed with.
|
|
102
|
+
|
|
103
|
+
=== Numbers
|
|
104
|
+
|
|
105
|
+
Numbers are kept as written. `number` is the digit string, and
|
|
106
|
+
`DecimalNumber` has `whole`, `decimal` (the marker as matched, for example
|
|
107
|
+
`.` or `٫`) and `fractional`. Nothing is converted to a float.
|
|
108
|
+
|
|
109
|
+
== Symbol resolution
|
|
110
|
+
|
|
111
|
+
`Umath::Symbols.resolve` visits every node that has a `spelling` and
|
|
112
|
+
looks it up in `data/umath-symbols.json`. That file is the legacy
|
|
113
|
+
symbol catalog exported from the pinned Plurimath commit. Like the legacy
|
|
114
|
+
transform, the lookup uses the spelling with surrounding whitespace
|
|
115
|
+
removed.
|
|
116
|
+
|
|
117
|
+
`spelling`:: The token as the grammar matched it, for example `α` for
|
|
118
|
+
`α` or `alpha` for `\alpha`.
|
|
119
|
+
`value`:: The Unicode text the catalog entry writes as UnicodeMath, for
|
|
120
|
+
example `α`.
|
|
121
|
+
`kind`:: `symbol` or `paren` for catalog entries. When the catalog has no
|
|
122
|
+
entry the kind is `unlisted`, and the value is the spelling with its
|
|
123
|
+
character references decoded. The legacy transform keeps such a spelling
|
|
124
|
+
as a plain symbol.
|
|
125
|
+
|
|
126
|
+
== Errors and positions
|
|
127
|
+
|
|
128
|
+
Errors follow the stage profile. A `ParseError` reports `input` (the
|
|
129
|
+
caller's text as UTF-8), `char_offset` and `byte_offset`, both measured in
|
|
130
|
+
the caller's text. The grammar reports positions in the preprocessed text.
|
|
131
|
+
`Umath::SourceMap` repeats each preprocessing step on the caller's
|
|
132
|
+
text and records where every character came from, so a grammar position can
|
|
133
|
+
be mapped back. Characters produced by one replacement, such as the
|
|
134
|
+
`α` that stands for `α`, all map to the position of the original
|
|
135
|
+
character. The end of the grammar input maps to the end of the formula part.
|
|
136
|
+
|
|
137
|
+
`NestingTooDeep` is raised when the engine cannot handle the input's depth.
|
|
138
|
+
That includes the Ruby stack running out while the model is built. In
|
|
139
|
+
measurements on the Ruby engine the grammar reached its limit first in
|
|
140
|
+
every case: 400 chained terms and 100 nested parentheses failed in the
|
|
141
|
+
grammar, and every input the grammar accepted (200 chained terms, 50 nested
|
|
142
|
+
parentheses, 100 nested fractions) was built and serialized without error.
|
|
143
|
+
|
|
144
|
+
== Checks
|
|
145
|
+
|
|
146
|
+
* `spec/model/model_shape_spec.rb` computes, from the normalized rules and
|
|
147
|
+
Parslet's flattening rules, which values each capture can hold, with code
|
|
148
|
+
written independently of the normalizer. It requires that every capture
|
|
149
|
+
holds exactly one kind and never nil, and that every capture is declared
|
|
150
|
+
with the type of its kind and every class declares exactly the captures
|
|
151
|
+
it can hold.
|
|
152
|
+
* `scripts/generate_grammar.rb --check` and `scripts/generate_model.rb
|
|
153
|
+
--check` require the committed grammar, wrapper table, normalized rules
|
|
154
|
+
and model classes to equal a fresh generation.
|
|
155
|
+
* `scripts/model_tree.rb` turns a model back into plain data and compares it
|
|
156
|
+
with the tree the bindings produced. lutaml-model ignores keys that a
|
|
157
|
+
class does not declare, so this is how a missing declaration is caught.
|
|
158
|
+
It also fails when an attribute holds a value of the wrong type.
|
|
159
|
+
* `scripts/replay_corpus.rb --staging corpora/model` replays the
|
|
160
|
+
representative rows in `corpora/model/`. For each row it checks that the
|
|
161
|
+
model holds the bound tree and compares the document with the stored
|
|
162
|
+
expectation. The expectations are the gem's own output, so they guard
|
|
163
|
+
against regressions; they are not legacy parity.
|
|
164
|
+
* In the replay workflow, every fixture and authored oracle row runs, on
|
|
165
|
+
the Ruby and the native engine, through the raw-tree parity spec
|
|
166
|
+
(`canonical(unwrap(raw tree)) == legacy tree`) and through
|
|
167
|
+
`scripts/replay_corpus.rb --inventory ... --rows ...`, which checks raw
|
|
168
|
+
parity again and that the model holds the bound tree. A row the legacy
|
|
169
|
+
grammar refused must raise `ParseError`, and a row without a formula
|
|
170
|
+
must give an empty document.
|
|
171
|
+
On the native engine, the rows in
|
|
172
|
+
`spec/parser/fixtures/oracle/native-divergences.yml` give another tree
|
|
173
|
+
than legacy, because Parsanol's native engine takes another alternative
|
|
174
|
+
of the same grammar. The grammar before the shape normalization gives the
|
|
175
|
+
same native trees, so the normalization does not cause them. Both checks
|
|
176
|
+
require those rows to still differ, so a fixed engine shows up as a
|
|
177
|
+
failure that asks to shorten the list.
|
|
178
|
+
* `scripts/verify_lml.rb` exports every model class with
|
|
179
|
+
`Lutaml::Model::Lml.export`, requires the committed
|
|
180
|
+
`model/umath.lml.json` and `model/umath.lml.checksums.txt` to
|
|
181
|
+
be byte-identical to a fresh export, and runs `Lutaml::Model::Lml.verify`
|
|
182
|
+
on each document. `Lml.verify` re-exports the loaded Ruby class, so it
|
|
183
|
+
does not prove that another implementation can rebuild the model. The
|
|
184
|
+
model uses no union types, which LML export rejects.
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
= UnicodeMath parser contract
|
|
2
|
+
:toc:
|
|
3
|
+
|
|
4
|
+
== Status and scope
|
|
5
|
+
|
|
6
|
+
This document covers syntax only. It records what the legacy UnicodeMath
|
|
7
|
+
parser inside Plurimath does today, which parts of it this gem takes over,
|
|
8
|
+
and how that legacy behaviour is captured so the gem can be compared against
|
|
9
|
+
it.
|
|
10
|
+
|
|
11
|
+
The gem has one public entry point, `Umath.parse(input, ...)`, which
|
|
12
|
+
returns a typed language model declared with LutaML Model (LML). That model
|
|
13
|
+
is described in link:model-contract.adoc[the model contract].
|
|
14
|
+
|
|
15
|
+
The raw parse tree that the grammar produces is not public API. It is an
|
|
16
|
+
internal boundary that the gem and its tests use, and it is the boundary
|
|
17
|
+
the parity captures compare first.
|
|
18
|
+
|
|
19
|
+
The repository holds the grammar, its compiled artifacts, the internal
|
|
20
|
+
raw-tree parser (`Umath::RawParser`), the typed model and the public
|
|
21
|
+
parser, the exported symbol catalog and the capture harness in
|
|
22
|
+
`scripts/oracle/`.
|
|
23
|
+
|
|
24
|
+
== Legacy source
|
|
25
|
+
|
|
26
|
+
All legacy facts below come from one pinned commit:
|
|
27
|
+
|
|
28
|
+
* Repository: https://github.com/plurimath/plurimath
|
|
29
|
+
* Commit: `e7ab4a5186c9be96f62d670574bc2c72f9365ad4` (Plurimath 0.11.17)
|
|
30
|
+
|
|
31
|
+
The capture harness refuses to run against any other commit or against a
|
|
32
|
+
checkout with local changes.
|
|
33
|
+
|
|
34
|
+
The UnicodeMath code at that commit (line counts from `wc -l`):
|
|
35
|
+
|
|
36
|
+
[cols="2,1,4"]
|
|
37
|
+
|===
|
|
38
|
+
| File | Lines | Role
|
|
39
|
+
|
|
40
|
+
| `lib/plurimath/unicode_math.rb` | 22 | Facade class: `new(text).to_formula`
|
|
41
|
+
| `lib/plurimath/unicode_math/parser.rb` | 56 | Preprocessing, then grammar, then transform
|
|
42
|
+
| `lib/plurimath/unicode_math/parse.rb` | 302 | Grammar class (`Parsanol::Parser`), root rule `expression`
|
|
43
|
+
| `lib/plurimath/unicode_math/parsing_rules/*.rb` | 800 | Grammar rule modules: masked, sub/sup, common, constants, absence, helper
|
|
44
|
+
| `lib/plurimath/unicode_math/constants.rb` | 1029 | Symbol tables used by the grammar and the transform
|
|
45
|
+
| `lib/plurimath/unicode_math/transform.rb` | 4058 | Tree to `Plurimath::Math::Formula` transform
|
|
46
|
+
| `lib/plurimath/unicode_math/utility.rb` | 294 | Transform helpers; subclass of `Plurimath::Utility`
|
|
47
|
+
|===
|
|
48
|
+
|
|
49
|
+
== Boundaries
|
|
50
|
+
|
|
51
|
+
A UnicodeMath parse passes four boundaries. The capture harness records each
|
|
52
|
+
one separately, so a difference can be traced to the first boundary where it
|
|
53
|
+
appears.
|
|
54
|
+
|
|
55
|
+
=== 1. Caller input and options
|
|
56
|
+
|
|
57
|
+
The caller passes a string and, optionally, `locale:`. Through
|
|
58
|
+
`Plurimath::Math.parse(text, :unicode, locale: ...)` the locale sets
|
|
59
|
+
`Plurimath.configuration.locale` for the duration of the call, and the
|
|
60
|
+
grammar reads its decimal marker from that configuration. Without a locale
|
|
61
|
+
the decimal marker is the configuration default.
|
|
62
|
+
|
|
63
|
+
Input bytes are kept exactly. Input that is not valid UTF-8 is recorded as
|
|
64
|
+
bytes, not repaired.
|
|
65
|
+
|
|
66
|
+
=== 2. Preprocessing
|
|
67
|
+
|
|
68
|
+
`Plurimath::UnicodeMath::Parser.new(text)` rewrites the input before the
|
|
69
|
+
grammar sees it. In order, as written in `parser.rb`:
|
|
70
|
+
|
|
71
|
+
. Labels. A `#` inside `✎(...)` is protected; the text is then split on
|
|
72
|
+
`#`. The first part is kept as the formula and, when there was more than
|
|
73
|
+
one part, the last part becomes the label (`@splitted`). Parts between the
|
|
74
|
+
first and the last are dropped.
|
|
75
|
+
. The formula text is HTML-entity encoded with hexadecimal references, then
|
|
76
|
+
`&` and `"` are turned back into `&` and `"`.
|
|
77
|
+
. Comments: everything from the first `⫷` (`⫷`) to the last
|
|
78
|
+
`⫸` (`⫸`) is removed.
|
|
79
|
+
. `\\` becomes `\`.
|
|
80
|
+
. `\uXXXX` escapes (one to five hex digits) become `&#xXXXX;`.
|
|
81
|
+
. Leading and trailing whitespace is stripped.
|
|
82
|
+
|
|
83
|
+
The grammar consumes this encoded text, not the caller's text. Positions in
|
|
84
|
+
grammar errors and in raw-tree slices are positions in the encoded text.
|
|
85
|
+
|
|
86
|
+
The gem's public `parse` owns this preprocessing and reports refusal
|
|
87
|
+
positions in the caller's text (see the model contract). The low-level parse
|
|
88
|
+
of already preprocessed text is an internal boundary.
|
|
89
|
+
|
|
90
|
+
=== 3. Raw grammar tree
|
|
91
|
+
|
|
92
|
+
`Plurimath::UnicodeMath::Parse.new.parse(text)` runs the grammar with
|
|
93
|
+
Parsanol in `compatibility: :parslet` mode. The result is a tree of hashes
|
|
94
|
+
and arrays whose leaves are `Parsanol::Slice` objects. When preprocessing
|
|
95
|
+
found a label, the facade wraps the tree as
|
|
96
|
+
`{labeled_tr_value: tree, labeled_tr_id: label}` before the transform; the
|
|
97
|
+
capture records the grammar tree and the label separately.
|
|
98
|
+
|
|
99
|
+
The grammar refuses input by raising `Parsanol::ParseFailed`. The error
|
|
100
|
+
message names the expected alternatives and a line and character position
|
|
101
|
+
in the encoded text.
|
|
102
|
+
|
|
103
|
+
The grammar is not self-contained: `ConstantsRules` builds alternations from
|
|
104
|
+
`Constants` when the grammar loads, and `Constants.wrapper_symbols` reads
|
|
105
|
+
Plurimath's symbol catalog (`Utility.symbols_hash(:umath)`). The
|
|
106
|
+
decimal-marker rule reads `Plurimath.configuration`. A gem grammar must take
|
|
107
|
+
these inputs from committed data, not from a live Plurimath.
|
|
108
|
+
|
|
109
|
+
=== 4. Facade formula and writers
|
|
110
|
+
|
|
111
|
+
`Plurimath::Math.parse` applies the transform and returns a
|
|
112
|
+
`Plurimath::Math::Formula`. Grammar refusals and transform failures both
|
|
113
|
+
surface as `Plurimath::Math::ParseError`. The capture records the formula
|
|
114
|
+
object graph and the exact output of the `to_asciimath`, `to_latex`,
|
|
115
|
+
`to_mathml`, `to_umath`, `to_omml` and `to_html` writers.
|
|
116
|
+
|
|
117
|
+
== What the gem must keep (syntax)
|
|
118
|
+
|
|
119
|
+
* Every input the legacy grammar accepts at the pinned commit is accepted,
|
|
120
|
+
and every input it refuses is refused.
|
|
121
|
+
* For accepted input, the internal raw tree, with the captures added by
|
|
122
|
+
the shape normalization removed (`scripts/unwrap.rb`, see below), matches
|
|
123
|
+
the legacy raw tree in the canonical tagged form below: same keys, same
|
|
124
|
+
order, same slice text and offsets.
|
|
125
|
+
* Preprocessing matches the legacy preprocessing for the same input and
|
|
126
|
+
locale, including label splitting and comment removal.
|
|
127
|
+
* A refusal reports an error category and a position. The position is part
|
|
128
|
+
of the contract; the wording of the grammar's error message is not, unless
|
|
129
|
+
a corpus row depends on it.
|
|
130
|
+
|
|
131
|
+
== Grammar and artifacts
|
|
132
|
+
|
|
133
|
+
`data/umath-grammar.json` is a one-time export of the legacy grammar
|
|
134
|
+
at the pinned commit, written by `scripts/generate_grammar.rb --export`.
|
|
135
|
+
`scripts/generate_grammar.rb` turns it into `grammars/umath.parg`;
|
|
136
|
+
rules that read the decimal marker are emitted once per marker, giving the
|
|
137
|
+
entries `dot`, `comma` and `arabic`.
|
|
138
|
+
|
|
139
|
+
=== Capture-shape normalization
|
|
140
|
+
|
|
141
|
+
Under Parslet's flattening rules the legacy grammar gives one capture name
|
|
142
|
+
text, a hash or a list depending on which alternative matched. Before
|
|
143
|
+
writing the grammar, `scripts/umath_shapes.rb` rewrites the rules so
|
|
144
|
+
that every capture always holds one kind. It only adds captures, which never
|
|
145
|
+
changes what the grammar accepts. Rules N1 to N7 are the shared ones in
|
|
146
|
+
`scripts/normalize_shapes.rb`, the same file in every stage gem; N8 and the
|
|
147
|
+
checks below are UnicodeMath's, in `scripts/umath_shapes.rb`:
|
|
148
|
+
|
|
149
|
+
N1 text alternative:: a text-only alternative of a node slot becomes
|
|
150
|
+
`(x) as literal`.
|
|
151
|
+
N2 empty alternative:: an optional part of a node slot becomes
|
|
152
|
+
`x / "" as empty`.
|
|
153
|
+
N3 list in node slot:: a list in a node slot becomes `(x) as list`.
|
|
154
|
+
N4 list slot:: a capture that can repeat always holds a list.
|
|
155
|
+
N5 text sibling:: text next to captures in a sequence that legacy could
|
|
156
|
+
fold to text becomes `literal` (or `literal_2`, ...).
|
|
157
|
+
N6 forced rule:: a rule that can return text or a node is rewritten once,
|
|
158
|
+
for all its call sites.
|
|
159
|
+
N7 presence capture:: a capture of a lookahead (always nil) becomes
|
|
160
|
+
`(x "" as present) as name`.
|
|
161
|
+
N8 symbol spelling:: the text of a symbol capture (one the legacy
|
|
162
|
+
transform looks up in the symbol catalog, listed in
|
|
163
|
+
`scripts/generate_grammar.rb`) is captured as `spelling`; in the symbol
|
|
164
|
+
captures that hold a node (`symbol`, `open_paren`, `close_paren`,
|
|
165
|
+
`prime_accent_symbols`) N1 uses `spelling` instead of `literal`.
|
|
166
|
+
|
|
167
|
+
Generation fails when a capture still holds more than one kind, or nil, when
|
|
168
|
+
one sequence can carry the same added capture twice, or when a sequence can
|
|
169
|
+
have more than one hash part after a `list` capture (legacy appends each of
|
|
170
|
+
them to the list separately, and the unwrap could not tell them apart). The
|
|
171
|
+
generator writes, beside the grammar, `grammars/umath.wrappers.json`
|
|
172
|
+
(the added capture names, how to remove each one, and how often each rule
|
|
173
|
+
applied) and `grammars/umath.normalized-rules.json` (the normalized
|
|
174
|
+
rules, from which `scripts/generate_model.rb` writes the model classes).
|
|
175
|
+
`ruby scripts/generate_grammar.rb --check` and
|
|
176
|
+
`ruby scripts/generate_model.rb --check` fail when any of these files is
|
|
177
|
+
stale; CI runs both.
|
|
178
|
+
|
|
179
|
+
`scripts/unwrap.rb` is the test-only inverse. It replaces each added capture
|
|
180
|
+
with the value legacy gave there and repeats Parslet's own folding
|
|
181
|
+
(`Parsanol::Atoms::CanFlatten#merge_fold` and `#flatten_repetition`). The
|
|
182
|
+
parity replay, the model replay and the stage-cases generator use it; the
|
|
183
|
+
gem does not.
|
|
184
|
+
|
|
185
|
+
`scripts/compile.rb` compiles the grammar with
|
|
186
|
+
`parsanol parg compile ... --bindings all` (Parsanol 1.3.131 or later). That
|
|
187
|
+
writes `artifacts/umath.json` and one bindings artifact per Parsanol
|
|
188
|
+
binding target, `artifacts/umath.bindings.<target>.json`. With
|
|
189
|
+
`--determinism` the script compiles a second time and requires identical
|
|
190
|
+
bytes for every file. It then writes `artifacts/umath.checksums.txt`,
|
|
191
|
+
which records the compiler version, the envelope and per-target binding
|
|
192
|
+
checksums and the sha256 of every file. CI compiles again with the compiler
|
|
193
|
+
and Ruby version that the committed artifact records, and fails if any file
|
|
194
|
+
differs.
|
|
195
|
+
|
|
196
|
+
Each entry binds its root capture twice: `umath_tree` as `raw`, the
|
|
197
|
+
normalized tree with slices and offsets, and as `any` under the path `model`,
|
|
198
|
+
the plain tree that the typed model is built from. At load time the gem
|
|
199
|
+
checks the bindings artifact's kind, checksum, target and version, pairs it
|
|
200
|
+
with the parse artifact and requires both bindings. It never falls back to
|
|
201
|
+
another artifact.
|
|
202
|
+
|
|
203
|
+
== Symbol catalog
|
|
204
|
+
|
|
205
|
+
`data/umath-symbols.json` is the symbol catalog the legacy transform
|
|
206
|
+
uses: every UnicodeMath spelling of Plurimath's `Math::Symbols::Symbol` and
|
|
207
|
+
`Math::Symbols::Paren` classes, as `Plurimath::Utility.all_symbols_classes(:umath)`
|
|
208
|
+
returns them at the pinned commit. For each spelling it records the Unicode
|
|
209
|
+
text the class writes as UnicodeMath (`value`) and whether the class is a
|
|
210
|
+
symbol or a paren (`kind`). It has 3282 spellings, 64 of them parens.
|
|
211
|
+
|
|
212
|
+
`scripts/export_symbols.rb` writes the file. It needs a clean checkout of
|
|
213
|
+
the pinned commit and the oracle bundle:
|
|
214
|
+
|
|
215
|
+
[source,sh]
|
|
216
|
+
----
|
|
217
|
+
BUNDLE_GEMFILE=scripts/oracle/Gemfile BUNDLE_PATH=vendor/oracle-bundle \
|
|
218
|
+
bundle exec ruby scripts/export_symbols.rb ORACLE_CHECKOUT
|
|
219
|
+
----
|
|
220
|
+
|
|
221
|
+
Like the grammar export, this is a provenance step. The build and the gem
|
|
222
|
+
read only the committed file and never load Plurimath.
|
|
223
|
+
|
|
224
|
+
== Capture harness
|
|
225
|
+
|
|
226
|
+
`scripts/oracle/capture.rb` records the legacy behaviour for rows of an
|
|
227
|
+
inventory.
|
|
228
|
+
|
|
229
|
+
=== Inventory
|
|
230
|
+
|
|
231
|
+
Inventory files live in `corpora/oracle/inventory/` and use schema
|
|
232
|
+
`umath-oracle-inventory/1`. Each row has an `id`, exactly one of
|
|
233
|
+
`input` (text) or `input_base64` (exact bytes) and optional `options`
|
|
234
|
+
(currently `locale`). Ids must be unique across all files and safe as file
|
|
235
|
+
names. The harness stops before running anything if an id is duplicated or
|
|
236
|
+
a requested id is missing.
|
|
237
|
+
|
|
238
|
+
=== Processes and deadlines
|
|
239
|
+
|
|
240
|
+
Each row runs in a fresh worker process, so class-level caches in the legacy
|
|
241
|
+
grammar never leak from one locale to another. The worker reads its request
|
|
242
|
+
on standard input and writes one JSON result to file descriptor 3. Its
|
|
243
|
+
standard output and error go to log files, so stray output from Plurimath
|
|
244
|
+
cannot corrupt the result.
|
|
245
|
+
|
|
246
|
+
The supervisor measures a deadline with the monotonic clock. Reaching
|
|
247
|
+
end-of-file on the result pipe does not end the wait: the supervisor keeps
|
|
248
|
+
polling the process status until the process exits or the same deadline
|
|
249
|
+
passes. Only the deadline produces a timeout. A timed-out worker gets
|
|
250
|
+
`TERM`, then `KILL` after a grace period, sent to its process group.
|
|
251
|
+
|
|
252
|
+
=== Outcomes
|
|
253
|
+
|
|
254
|
+
[cols="1,3"]
|
|
255
|
+
|===
|
|
256
|
+
| Outcome | Meaning
|
|
257
|
+
|
|
258
|
+
| `succeeded` | The grammar produced a raw tree.
|
|
259
|
+
| `refused` | The grammar raised `Parsanol::ParseFailed`. This is data.
|
|
260
|
+
| `errored` | Preprocessing or the grammar raised another exception. This is data.
|
|
261
|
+
| `timed_out` | No exit before the deadline. The capture is incomplete.
|
|
262
|
+
| `crashed` | The worker died, exited non-zero, or exited without a complete, valid result. The capture is incomplete.
|
|
263
|
+
|===
|
|
264
|
+
|
|
265
|
+
Only the worker can report `succeeded`, `refused` or `errored`; a worker
|
|
266
|
+
result that claims any other outcome is treated as a crash. A timeout or
|
|
267
|
+
crash is never counted as a refusal.
|
|
268
|
+
|
|
269
|
+
Exceptions at the formula and writer boundaries are recorded inside the row
|
|
270
|
+
and do not change the row's outcome.
|
|
271
|
+
|
|
272
|
+
=== Checkpoints and resume
|
|
273
|
+
|
|
274
|
+
Each finished row is written as its own file, `rows/<id>.json`, through a
|
|
275
|
+
temporary file and a rename, so a row file is either complete or absent.
|
|
276
|
+
Each row stores a digest of its request and of the harness identity (oracle
|
|
277
|
+
commit, harness file checksums, Ruby version). On a rerun, rows with the same
|
|
278
|
+
digest and a data outcome are reused; timed-out, crashed and changed rows are
|
|
279
|
+
run again. `--max-attempts N` stops after N new rows, which the specs use to
|
|
280
|
+
test resuming.
|
|
281
|
+
|
|
282
|
+
=== Report
|
|
283
|
+
|
|
284
|
+
The report (schema `umath-oracle-report/1`) lists the expected ids,
|
|
285
|
+
counts per outcome, missing ids, the checksum of every row file, the
|
|
286
|
+
inventory files with their checksums and the harness identity. It is
|
|
287
|
+
`complete` only when every expected id has a row and no row timed out or
|
|
288
|
+
crashed. The capture exits 0 when the report is complete, 2 when it is not
|
|
289
|
+
and 1 when it could not start.
|
|
290
|
+
|
|
291
|
+
=== Canonical tagged form
|
|
292
|
+
|
|
293
|
+
Values are stored in a tagged form that keeps apart everything the parity
|
|
294
|
+
comparison needs to see:
|
|
295
|
+
|
|
296
|
+
* hash (`"t": "hash"`, entries in insertion order) and array (`"t": "array"`);
|
|
297
|
+
* symbol (`"sym"`) and string (`"str"`), for keys and values alike;
|
|
298
|
+
* a nil value (`"nil"`) and a missing key (no entry);
|
|
299
|
+
* the empty string and the empty array;
|
|
300
|
+
* parser slices (`"slice"`), with their text, character offset and byte
|
|
301
|
+
offset;
|
|
302
|
+
* any other object (`"obj"`), with its class and its instance variables in
|
|
303
|
+
definition order;
|
|
304
|
+
* strings that are not valid UTF-8 (`"bytes"`, base64);
|
|
305
|
+
* numbers as their Ruby string form, never converted.
|
|
306
|
+
|
|
307
|
+
Object addresses in exception messages are replaced with `0xADDR`, because
|
|
308
|
+
they change on every run.
|
|
309
|
+
|
|
310
|
+
== Running the capture
|
|
311
|
+
|
|
312
|
+
The oracle runs in its own bundle, separate from the gem's development
|
|
313
|
+
bundle. `scripts/oracle/Gemfile` lists the runtime dependencies of the
|
|
314
|
+
pinned Plurimath commit and `scripts/oracle/Gemfile.lock` fixes their
|
|
315
|
+
versions.
|
|
316
|
+
|
|
317
|
+
[source,sh]
|
|
318
|
+
----
|
|
319
|
+
git clone https://github.com/plurimath/plurimath ORACLE_CHECKOUT
|
|
320
|
+
git -C ORACLE_CHECKOUT checkout e7ab4a5186c9be96f62d670574bc2c72f9365ad4
|
|
321
|
+
|
|
322
|
+
BUNDLE_GEMFILE=scripts/oracle/Gemfile BUNDLE_PATH=vendor/oracle-bundle \
|
|
323
|
+
bundle install
|
|
324
|
+
|
|
325
|
+
bundle exec ruby scripts/oracle/capture.rb --oracle ORACLE_CHECKOUT \
|
|
326
|
+
--ids smoke-success,smoke-refusal --out tmp/oracle-capture
|
|
327
|
+
----
|
|
328
|
+
|
|
329
|
+
Set `ORACLE_BUNDLE_PATH` when the oracle bundle is installed somewhere other
|
|
330
|
+
than `vendor/oracle-bundle`.
|
|
331
|
+
|
|
332
|
+
`corpora/oracle/smoke/rows/` holds the rows of the smoke capture: one
|
|
333
|
+
accepted input and one refused input, run before any bulk capture. CI runs
|
|
334
|
+
the smoke capture again and compares each row's outcome and boundaries with
|
|
335
|
+
these files. To regenerate them, run the capture above with
|
|
336
|
+
`--out corpora/oracle/smoke` and delete the `logs/` and `report.json` it also
|
|
337
|
+
writes there.
|
|
338
|
+
|
|
339
|
+
The harness specs in `spec/oracle/` use a fake worker instead of Plurimath.
|
|
340
|
+
They cover success, refusal, a real deadline, crashes, end-of-file before
|
|
341
|
+
exit and resuming after a stop or a killed supervisor.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
= The plurimath-testsuite pin
|
|
2
|
+
|
|
3
|
+
The UnicodeMath stage corpus lives in
|
|
4
|
+
https://github.com/plurimath/plurimath-testsuite[plurimath-testsuite], in
|
|
5
|
+
immutable numbered generations under `stages/unicode/generations/`. Each
|
|
6
|
+
generation is listed, with the digest of its files, in the append-only ledger
|
|
7
|
+
`generations/unicode.yaml`. This gem replays exactly one of those
|
|
8
|
+
generations, named in `spec/fixtures/testsuite-pin.yml`:
|
|
9
|
+
|
|
10
|
+
[source,yaml]
|
|
11
|
+
----
|
|
12
|
+
schema: plurimath-stage/pin/1
|
|
13
|
+
repository: https://github.com/plurimath/plurimath-testsuite
|
|
14
|
+
commit: 2a18ff43a26716a60c1c4330d5107c26dc73963c
|
|
15
|
+
language: unicode
|
|
16
|
+
generation: 2
|
|
17
|
+
snapshot_digest: sha256:0a6f19b9a7836e71544d1a027e3f3ea2397dc5e9a1aba8b7834079b1dc91593d
|
|
18
|
+
----
|
|
19
|
+
|
|
20
|
+
Generation 2 keeps every case of generation 1 (the legacy corpus, produced by
|
|
21
|
+
Plurimath 0.11.12) byte for byte and adds a raw-tree row for each of its 72
|
|
22
|
+
cases, rejections included, written by `scripts/stage_cases.rb` at umath
|
|
23
|
+
1929f69.
|
|
24
|
+
|
|
25
|
+
== What CI checks
|
|
26
|
+
|
|
27
|
+
The `corpus` workflow checks out the testsuite at the pinned commit and then:
|
|
28
|
+
|
|
29
|
+
. runs the testsuite's own validator,
|
|
30
|
+
`ruby scripts/validate-stages.rb <checkout> --pin spec/fixtures/testsuite-pin.yml`,
|
|
31
|
+
which checks that the checkout is at the pinned commit, that the ledger has
|
|
32
|
+
the pinned generation with the pinned digest, and that the generation's
|
|
33
|
+
files hash to that digest;
|
|
34
|
+
. runs the same validator on copies of the pin with a wrong commit, a wrong
|
|
35
|
+
digest and a wrong generation, and fails if any of them is accepted;
|
|
36
|
+
. runs `spec/corpus`, which makes the same checks in
|
|
37
|
+
`scripts/replay_corpus.rb` fail on a small generated testsuite;
|
|
38
|
+
. runs `scripts/replay_corpus.rb --testsuite`, which repeats the pin checks
|
|
39
|
+
and then parses every row of every raw-tree payload of the generation with
|
|
40
|
+
`Umath::RawParser.parse` on the Ruby and the native engine. Each
|
|
41
|
+
engine's result, the preprocessed text and the tree in the legacy grammar's
|
|
42
|
+
shape or the refusal and its byte offset, must equal the row's expected
|
|
43
|
+
value in the canonical form of the testsuite's
|
|
44
|
+
`docs/tree-parity-contract.adoc`.
|
|
45
|
+
|
|
46
|
+
The rows whose source id is listed in
|
|
47
|
+
`spec/parser/fixtures/oracle/native-divergences.yml` are the exception on the
|
|
48
|
+
native engine: Parsanol's native engine takes another alternative of the same
|
|
49
|
+
grammar for those inputs, so their native result must still differ from the
|
|
50
|
+
expected value, and the replay fails once one of them matches, so the list
|
|
51
|
+
can be shortened. None of the listed inputs is in generation 2, whose rows
|
|
52
|
+
were written only where both engines agreed.
|
|
53
|
+
|
|
54
|
+
To run the replay locally:
|
|
55
|
+
|
|
56
|
+
[source,sh]
|
|
57
|
+
----
|
|
58
|
+
git clone https://github.com/plurimath/plurimath-testsuite tmp/testsuite
|
|
59
|
+
git -C tmp/testsuite checkout 2a18ff43a26716a60c1c4330d5107c26dc73963c
|
|
60
|
+
bundle exec ruby scripts/replay_corpus.rb --testsuite tmp/testsuite --report tmp/replay.json
|
|
61
|
+
----
|
|
62
|
+
|
|
63
|
+
The `replay` workflow checks out the testsuite at the same commit and fails if
|
|
64
|
+
its commit and the pin's differ. The `stage-cases` workflow writes the
|
|
65
|
+
generation after the latest one of its source commit, so it still reads
|
|
66
|
+
generation 1 at b4520d8; it also checks out the pinned commit and requires the
|
|
67
|
+
raw-tree payloads it writes to equal the pinned generation's byte for byte.
|
|
68
|
+
The same workflow also writes a typed-model generation (`scripts/stage_cases.rb
|
|
69
|
+
--boundary typed-model`) from the latest generation at 5e32851: every payload
|
|
70
|
+
copied byte for byte plus a `model-<group>.yaml` payload of typed models from
|
|
71
|
+
`Umath.parse` for each legacy payload, uploaded as the
|
|
72
|
+
`stage-generation-model` artifact.
|
|
73
|
+
|
|
74
|
+
== Parity with the legacy parser
|
|
75
|
+
|
|
76
|
+
The pinned generation's raw-tree rows were generated from the generation 1
|
|
77
|
+
inputs, so replaying them checks the current grammar against the trees this
|
|
78
|
+
gem produced when they were written. The comparison with the legacy parser
|
|
79
|
+
itself is made by the `replay` workflow: it captures the parse tree of the
|
|
80
|
+
pinned Plurimath for every UnicodeMath case of the testsuite's
|
|
81
|
+
`fixtures/unicode-expectations.yaml` and for the rows authored in this
|
|
82
|
+
repository, and requires the raw tree and the typed model built by
|
|
83
|
+
`Umath.parse` to match it on both engines, apart from the listed native
|
|
84
|
+
divergences. The legacy corpus itself is not changed by this gem: generation 1
|
|
85
|
+
stays as it was imported.
|
|
86
|
+
|
|
87
|
+
== Moving the pin
|
|
88
|
+
|
|
89
|
+
Commit, generation and digest change together, in one change, and only to a
|
|
90
|
+
generation that is already merged in the testsuite. Take the values from the
|
|
91
|
+
ledger entry, update `TESTSUITE_COMMIT` in the `corpus` and `replay` workflows
|
|
92
|
+
to the same commit, and let CI replay the new generation. A generation whose
|
|
93
|
+
rows this gem no longer reproduces is a regression to fix here, or a reason
|
|
94
|
+
for a new generation in the testsuite; the pin never points at a generation
|
|
95
|
+
the gem does not match.
|