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.
@@ -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 `&#x3b1;&#x3b2;`. 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 `&#x66b;`) 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 `&#x3b1;` 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
+ `&#x3b1;` 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
+ `&#x26;` and `&#x22;` are turned back into `&` and `"`.
77
+ . Comments: everything from the first `&#x2af7;` (`⫷`) to the last
78
+ `&#x2af8;` (`⫸`) 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.