Skip to content

core: read a sentence into a check - #181

Merged
jbpros merged 15 commits into
mainfrom
core/sentence-parser
Oct 13, 2026
Merged

jbpros merged 15 commits into
mainfrom
core/sentence-parser

Conversation

@jbpros

@jbpros jbpros commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

First implementation PR after #180: the parser for spec/sentences.abnf, in ergo.rego, not yet called by ergo.report.

What it does. _sentence(text) reads a check, _for_line(text) a for line and _from_line(text) a from line, each into a JSON shape that mirrors the sentence (quantifier, path, where conditions, phrase and its arguments, or terms for count of, sum of and plus), or into {"problems": [...]} when the line is written wrong. _s_check_text and its two siblings print a parsed line back in one spelling: is before a value and equals before a field, is one of before values and is in before a path, an before object, item 3 of for a position that ends a path, single quotes for a string with a backslash when that reads back the same, one space between words. Two spellings of one check give the same parse: the dot in front of a key, brackets or words for a position, and runs of spaces are spelling only.

How. The grammar is flat, so the parser is layered regular expressions: one splits the assertions on and, one reads an assertion's parts, one reads the conditions of a where, one picks the phrase. Each split piece has to end at its separator or the end of the line, so the regex engine backtracks past a short match like false in false-positive. Rego can't recurse and this needs none.

Wasm. The first version took over two minutes for the corpus under Wasm, because Wasm rebuilds a constant rule at every reference and the patterns were a tree of constants. They're now built once per call in _s_patterns and passed down, and the phrase union is factored by argument kind. The corpus test takes 20 s under Wasm and 0.3 s with opa test.

Tests. Every one of the 306 corpus lines parses and prints back byte for byte, from spec/sentences-corpus.json, which spec/sentences_corpus.py writes from the markdown. The CI check that it's current is in #185, after this PR, so the squashed commit stays core:. Unit tests cover each phrase, each path form, the printed spellings, and the messages for sentences written wrong.

Review. A fresh Opus review found twelve things, four of them real bugs: a leading-zero index and a bad string escape stopped the evaluation, keys starting with true, false, null or empty were rejected, and the parser accepted a where on a plain path, a lone number on the left, a non-comparison after count of, and a for name given twice. All twelve are fixed in the second core: commit, with the spec changed in two places it was wrong: when a string prints in single quotes, and the is empy row of the written-wrong table, since that sentence parses as a comparison with a field.

Second review. A second review compared the parser with the ABNF, converted to a Lark grammar, on 6,000 changed corpus lines and 11,616 random sentences built from the grammar, and printed back every line the parser accepted. It found three things, fixed in the last commits:

  • x is between -6 and -80e-08923553 crashed OPA with panic: illegal value. OPA (1.19.0, 1.20.2 and 1.21.2) panics when it compares a number whose exponent is beyond ±1,000,000, reported as Comparing a number whose exponent is beyond ±1,000,000 panics with "illegal value" open-policy-agent/opa#9365. The parser now works out a number's size from its text before to_number, and applies the rule a policy number already follows: 0 or a magnitude between 2.2250738585072014e-308 and 1.7976931348623157e308. Otherwise the sentence gets number out of range: <number>. sentences.md says so.
  • That also fixes x is between 1 and 1e400, which failed with "doesn't parse" because OPA can't compare numbers above 1.8e308.
  • The ABNF gave named by each of a left-side path, where "T" is a key. The parser and sentences.md read a right-side path, so the ABNF now says right-path.

Problems now keep their column order, so column 6 comes before column 20. A third review found that x is 1 within -1 parsed, though the spec says the number after within is 0 or more. It now gets "within" needs a number of 0 or more.

Not in this PR. Wiring into ergo.report and the semantics of each phrase. spec/sentences.md now says ergo reads sentences but runs none.

Checks run here: opa check, opa fmt, opa test with coverage (every line reached), --strict-builtin-errors, Regal, no comments, OPA 1.20.2 fmt, Wasm tests in Docker, and the Wasm JS runtime script (1009 pass, 10.5 MB). On OPA 1.19, two of the 11,616 random sentences didn't print back the same inside one big evaluation, because 1.19 thinks 0.5e-7 equals 0.5e-70. That's the known 1.19 number bug, and OPA 1.20.2 prints all of them back correctly.

🤖 Generated with Claude Code

@jbpros jbpros added the core Relates to the ergo library or format themselves. label Oct 12, 2026
jbpros and others added 10 commits October 12, 2026 23:33
spec/sentences_corpus.py pulls every sentence, for line and from line
out of sentences-corpus.md into sentences-corpus.json, which OPA loads
as data.spec.sentence_corpus, so a test can parse each one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the parser for spec/sentences.abnf to ergo.rego: _sentence reads
a check, _for_line a for line and _from_line a from line, each into a
JSON shape that mirrors the sentence, or into a list of problems when
the line is written wrong. _s_check_text and its two siblings print a
parsed line back in the spelling the report will use.

The grammar is flat, so the parser is layered regular expressions: one
splits the assertions, one reads an assertion's parts, one reads the
conditions of a where, and the phrase is picked by its leading words.
The patterns are built once per call in _s_patterns and passed down,
because Wasm rebuilds a constant rule at every reference, and the
phrase union is factored by argument kind to keep them small. Every
corpus line parses and prints back unchanged, in 11 seconds for all
306 under Wasm.

Nothing calls it from ergo.report yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…take within

Single quotes are only safe for a string with a backslash when it has
no single quote, no two backslashes in a row, no backslash at the end
and no control character, or parsing the printed sentence gives a
different value. The written-wrong table loses the is empy row, since
that sentence parses as a comparison with a field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An index with a leading zero and a string with a bad escape stopped
the evaluation instead of giving a problem. A value or key starting
with true, false, null or empty was rejected, because the split took
the first match; each piece now has to end at the separator or the
end. The parser accepted a where on a plain path, a lone number on
the left, a phrase other than a comparison after count of, sum of or
plus, and a for name given twice; each is a problem now. A quoted
grammar word is a key, and the dot in front of a key is spelling, not
part of the parse, so the same check always reads the same. A position
written in brackets at the end of a path reads and prints as item n of,
and a string prints in single quotes only when that reads back to the
same value. The messages for an unclosed quote, a missing phrase, a
missing number after within and an unknown  now say what the spec
promises.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
So that count of item 1 of x, which is how the report prints
count of x[0], is a sentence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ree messages

count of x[0] printed as count of item 1 of x, which did not parse,
because a count or sum took no position. The unknown-name message
skips quoted strings and names each name once, the invalid-string
message gives a column so that a tab or newline in the string does not
break the row, and the missing-phrase message quotes is and exists as
the spec does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…time lacks

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ike it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@jbpros
jbpros force-pushed the core/sentence-parser branch from ea72f07 to f6c1f52 Compare October 12, 2026 21:34
jbpros and others added 4 commits October 13, 2026 07:06
The parser and sentences.md already read it as a right-side path, where
a quoted word is a value and not a key, so $input named by each of "T"
doesn't parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A number has to be 0 or have a magnitude between 2.2250738585072014e-308
and 1.7976931348623157e308, as in a policy. The parser now checks the
size from the text first. Comparing a number whose exponent is beyond
one million crashed OPA with "panic: illegal value", as in
x is between -6 and -80e-08923553, and a number above 1.8e308 made
x is between 1 and 1e400 fail with "doesn't parse".

Problems now keep the order of their columns, so column 6 comes before
column 20.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The squashed commit is core:, so the workflow change goes in a ci: pull
request after this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ttern

x is between 1e400 and 1 said "min above max" too, and x matches 1e400
printed the parser's own marker as an invalid pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sentences.md says the number after within is 0 or more, as REFERENCE.md
says for today's within, but x is 1 within -1 parsed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jbpros
jbpros marked this pull request as ready for review October 13, 2026 05:55
@jbpros
jbpros merged commit 68711f8 into main Oct 13, 2026
7 checks passed
@jbpros
jbpros deleted the core/sentence-parser branch October 13, 2026 05:55
jbpros added a commit that referenced this pull request Oct 13, 2026
Runs `python3 spec/sentences_corpus.py --check` in the `spec` job and
lists it in `AGENTS.md`. It fails when `spec/sentences-corpus.json` no
longer holds every line of `spec/sentences-corpus.md`.

This goes after #181, which adds the script and the JSON. It's separate
so that #181's squashed commit stays `core:`. Its base is
`core/sentence-parser`, so once #181 merges it needs rebasing onto
`main`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
jbpros added a commit that referenced this pull request Oct 13, 2026
Moves every CI job to OPA 1.20.2 and stops testing OPA 1.19.

OPA 1.19 compares some numbers wrongly: `1 >= 100.0` is true, so
`"min_subjects": 100.0` passes with one subject, and `range` and
`compare` can be wrong too. The #181 review hit it again, where `x is
between 100.0 and 1` parses on 1.19 instead of giving "min above max". A
test for that can't pass while CI runs 1.19. 1.20.2 is the version the
Kosli CLI embeds.

- `.github/workflows/test.yml`: `test`, `wasm`, `wasm-js` and `spec` run
1.20.2. The separate `opa-1-20` job goes, since `test` now does what it
did. `test`, `wasm` and `title` keep their names, as the ruleset
requires them.
- `README.md` says ergo needs 1.20.1 or later. 1.20.0 compares correctly
but stops with `panic: illegal value` on `0.0 == 1.5`
(open-policy-agent/opa#9098). I ran each example on 1.19.0, 1.20.0,
1.20.1 and 1.20.2.
- `AGENTS.md`, `CONTRIBUTING.md`, and the site's `opaVersion` and bakery
page name 1.20. The site files are `site:`, but they only say which OPA
version ergo needs, so they go with this change.

Checks run here with OPA 1.20.2: every check in `AGENTS.md`, coverage,
the Wasm tests in Docker, and the Wasm JS runtime script (1009 pass,
10.5 MB).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Relates to the ergo library or format themselves.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant