Skip to content

Support ranges with negative endpoints in search parsing - #4

Closed
roed-math wants to merge 8 commits into
mainfrom
ai/t07-negative-ranges
Closed

Support ranges with negative endpoints in search parsing#4
roed-math wants to merge 8 commits into
mainfrom
ai/t07-negative-ranges

Conversation

@roed-math

Copy link
Copy Markdown
Owner

Search inputs like nilpotency=-1..3, nilpotency=-1-3 or cm=-4..-1 used to flash "not a valid input" because LIST_RE rejected signed range endpoints, and the splitters located the range dash with arg.index("-", 1), which picks the wrong dash in inputs like --4 (what prep_ranges produces from ..-4). This PR extends LIST_RE/LIST_FLOAT_RE to allow signed endpoints and introduces RANGE_DASH_RE to identify the separator dash (first dash preceded by a digit or decimal point, or a leading dash directly before a minus sign), using it consistently in parse_range, parse_range2, parse_range3, parse_range_float and parse_ints_to_list. A single leading - still denotes a negative number (-5 means −5), matching existing parse_range2 semantics. Along the way this fixes open-left ranges with negative endpoints (..-4), -4..-1 in parse_ints_to_list (used e.g. by ECNF CM-discriminant search), and scientific-notation floats (1e-5, 1e-5-2e-4), which previously errored due to an inverted exponent sign in find_prec. Adds lmfdb/utils/test_search_parsing.py with focused unit tests; galois_groups, maass_forms and number_fields suites pass, and the issue's URLs were verified through the test client. Addresses LMFDB#3825.

🤖 Generated with Claude Code

roed314 and others added 2 commits July 19, 2026 00:54
Searches like nilpotency=-1..3 or cm=-4--3 failed because LIST_RE
rejected any range endpoint with a minus sign, and the range splitters
located the separator dash with arg.index('-', 1), which picks the sign
dash in inputs like --4 (produced by prep_ranges from ..-4).

Extend LIST_RE (and LIST_FLOAT_RE) to allow signed endpoints and add
RANGE_DASH_RE, which identifies the separator as the first dash preceded
by a digit or decimal point (so a leading dash stays a minus sign and a
dash after e stays an exponent sign), or a leading dash directly before
a minus sign (omitted lower endpoint).  Use it in parse_range,
parse_range2, parse_range3, parse_range_float and parse_ints_to_list;
parse_ints_to_list now also checks .. before -.  In parse_range_float,
add the missing open-left branch and fix find_prec's inverted exponent
sign, which made scientific-notation input crash show_float once it
reached the singleton path.

Verified with new unit tests in lmfdb/utils/test_search_parsing.py plus
the galois_groups, maass_forms and number_fields test suites, and by
checking the issue's GaloisGroup nilpotency URLs and EllipticCurve CM
searches through the test client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The T07 PR added lmfdb/utils/test_search_parsing.py but left the CI
test-file census asserting 43 files, so every matrix job failed at the
"checking that we didn't miss any test files" step before running any
tests or linting.

- python-package.yml: bump census count 43 -> 44.
- matrix_includes.json: assign the new module to the shard that already
  runs lmfdb/tests/test_utils.py (both proddb and devmirror copies) so
  its tests actually execute; add "utils" to that shard's display folders.

Coordination: open PR #24 (ai/t28-ui-knowls) adds another new test file
needing the same fix. Each PR bumps the census by one for its own file,
so whichever of #4/#24 merges second must bump again (44 -> 45): a
trivial, intentional same-line conflict on the census line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Owner Author

Addressed the external review's P1 finding (CI test-file census): commit 21245d2 bumps the census 43→44 in python-package.yml and registers lmfdb/utils/test_search_parsing.py in both the proddb and devmirror shard entries of matrix_includes.json (same shard as test_utils.py), so the new tests actually run. Verified the exact census shell command passes at 44 and the new test file passes (9 tests).

Coordination note: PR #24 adds another test file with the same census fix — whichever merges second must bump 44→45 (deliberate same-line conflict).

Drive-by observation, out of scope: pre-existing test_workshoplinks.py is counted by the census but assigned to no shard, so it never runs in CI.

roed-math pushed a commit that referenced this pull request Jul 19, 2026
The PR added lmfdb/knowledge/test_knowledge.py but left the Tests
workflow asserting exactly 43 test files, so every matrix job failed at
the census step before tests ran. Bump the count to 44 and assign the
new module to the knowls shard (alongside test_dynamic_knowls.py) in
both the proddb and devmirror matrix entries so it is actually run.

Verified: replicated census command (find ... | wc -l == 44) passes;
matrix_includes.json parses and lists the file in both shards;
sage -python -m pytest lmfdb/knowledge/test_knowledge.py -> 4 passed;
pyflakes clean.

Note: open PR #4 (ai/t07-negative-ranges) applies the same census bump
for its own new test file; the second to merge re-bumps to 45 (trivial
same-line conflict, intentional).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roed314

roed314 commented Jul 20, 2026

Copy link
Copy Markdown

LGTM

roed314 and others added 5 commits August 4, 2026 17:27
…ranges

# Conflicts:
#	.github/workflows/matrix_includes.json
… (review fix)

Two P1 findings from the second review of this PR.

1. The signed alternatives added to LIST_RE and LIST_FLOAT_RE were ambiguous:
   every negative singleton matched both the singleton branch and the branch
   with an omitted lower endpoint, so rejecting a comma separated list of n
   negative singletons took time exponential in n ("-1," * 25 + "x" took over
   two seconds).  Both patterns are applied to raw search input in the request
   thread, so that is a denial of service risk.  Rewrite them with a factored
   item grammar in which each comma separated item has exactly one parse;
   rejecting 5000 items now takes under a millisecond.  A bare "-" is no longer
   accepted as an item (it used to be accepted here and fail later in int()).

2. prep_ranges folded every ".." into "-" before the parsers ran, so "..10"
   reached parse_ints as "-10" and silently became a search for the value -10
   rather than for values at most 10 (and "..4.5" a search near -4.5 in
   parse_floats).  Fold ".." only when a lower endpoint precedes it, and let
   LIST_RE and LIST_FLOAT_RE accept a leading ".."; parse_range2 and
   parse_range_float already handled it.  Parsers whose own regexes do not
   accept ".." (parse_posints, parse_signed_ints, parse_element_of) now reject
   "..10" with the usual invalid input message instead of silently searching
   for -10.

Verified: sage -python -m pytest on lmfdb/utils/test_search_parsing.py plus the
galois_groups, ecnf, maass_forms and number_fields suites, 79 passed; pyflakes
and ruff clean; test client checks of the issue's Galois group searches, the
ECNF CM discriminant search and the Maass spectral parameter search, inspecting
the query actually sent to postgres (nilpotency=..3 now gives {'$lte': 3}).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main gained lmfdb/tests/test_connection_reset.py, which also set the census to
44; this branch had set it to 44 for lmfdb/utils/test_search_parsing.py, so the
merge kept 44 without a conflict even though the tree now holds 45 test files.
Both new files are already assigned to shards in matrix_includes.json.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
prep_ranges no longer folds a leading .. into a dash, so --3 now reaches
LIST_RE only when a user types it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Owner Author

Second review pass requested two P1 changes; both are addressed, and the branch is now merged with main.

1. Exponential backtracking in LIST_RE / LIST_FLOAT_RE

The signed alternatives I added were ambiguous for every negative singleton: -1 matched both the singleton branch and the branch with an omitted lower endpoint. Rejecting a comma-separated list of n negative singletons therefore took time exponential in n ("-1,"*20 + "x" → 0.32 s, *22 → 1.3 s here; LIST_FLOAT_RE was worse). These patterns run on raw search input in the request thread, so that is a request-thread DoS.

Both are rewritten with a factored item grammar in which every comma-separated item has exactly one parse:

RANGE_SEP_STR      = r"(?:\.\.|-)"
UNSIGNED_INT_STR   = r"\d+"
SIGNED_INT_STR     = r"-?\d+"
INT_ITEM_STR       = r"(?:-?\d+(?:(?:\.\.|-)(?:-?\d+)?)?|--\d+|\.\.-?\d+)"
LIST_RE            = r"^INT_ITEM(?:,INT_ITEM)*$"
UNSIGNED_FLOAT_STR = r"(?:(?:\d+(?:[.]\d*)?|[.]\d+)(?:e[-+]?\d+)?|\d+/\d+)"
FLOAT_STR          = r"-?" + UNSIGNED_FLOAT_STR      # same language as before
FLOAT_ITEM_STR     = r"(?:FLOAT(?:(?:\.\.|-)(?:FLOAT)?)?|--UNSIGNED_FLOAT|\.\.FLOAT)"

Rejecting 5000 items now takes 0.45 ms (LIST_RE) / 0.99 ms (LIST_FLOAT_RE), and a fuzz over 22 repeated-unit shapes shows no blowup anywhere. A bare - is no longer accepted as an item, as asked. I also checked the file's other list patterns (LIST_POSINT_RE, LIST_RAT_RE, QQ_LIST_RE, SIGNED_LIST_RE, MULTISET_RE, the BRACKETED_*) for the same defect: all linear.

The new test_lists_reject_malformed_input_promptly runs each match in a subprocess with a 30 s timeout, so a return of exponential behaviour fails the test instead of hanging the run. Confirmed it times out on the old patterns and passes instantly on the new ones.

2. ..10 silently becoming -10

Rather than reject, this now works as written. prep_ranges folded every .. into - before the parser ran; it now folds .. only when a lower endpoint precedes it (RANGE_DOTS_RE = (?<=\d)\.\.), and LIST_RE/LIST_FLOAT_RE accept a leading .., which parse_range2/parse_range_float already knew how to handle:

parse_ints({"c": "..10"}, query, "c")     # {"c": {"$lte": 10}}
parse_ints({"c": "-5,..10"}, query, "c")  # {"$or": [{"c": -5}, {"c": {"$lte": 10}}]}
parse_floats({"c": "..4.5"}, query, "c")  # {"c": {"$lte": 4.5...}}

The tests for these go through the decorated public parsers, not just the helpers. ..-4 now stays ..-4 rather than being rewritten to --4 (both forms are still accepted), and the echo of every previously-working input is unchanged, since -4..-1 still folds to -4--1.

Parsers whose own regex does not accept ..parse_posints, parse_signed_ints, parse_element_of/integer_options — now reject ..10 with the usual invalid-input flash instead of silently searching for -10 (checked they flash rather than 500). Extending open-left support to those is a natural follow-up; I left it out of this PR.

Merge with main, and the census

The branch picked up a merge of main while I was working. Merged it in (no conflicts), but note a silent one: main added lmfdb/tests/test_connection_reset.py and set the census to 44, which is the same number this branch had set for test_search_parsing.py, so git kept 44 without a conflict while the tree actually holds 45 test files. Bumped to 45 in a separate commit; both new test files are assigned to 2 shard entries each in matrix_includes.json. (lmfdb/tests/test_workshoplinks.py is still counted but unsharded — pre-existing, untouched.)

Verified

  • lmfdb/utils/test_search_parsing.py → 12 passed (was 9), on both the pre-merge and post-merge trees.

  • galois_groups, ecnf, maass_forms, number_fields suites → 79 passed on the pre-merge tree; the post-merge re-run was still going when I wrote this, so treat CI as the authority there.

  • pyflakes and ruff check --preview --select=E722 clean on both changed files (pylint isn't installed here).

  • Test client, inspecting the query actually sent to postgres rather than just the status code:

    input query
    /GaloisGroup/?nilpotency=-1..3 {'nilpotency': {'$gte': -1, '$lte': 3}}
    /GaloisGroup/?nilpotency=..-1 {'nilpotency': {'$lte': -1}}
    /GaloisGroup/?nilpotency=..3 {'nilpotency': {'$lte': 3}} (was a search for −3)
    /GaloisGroup/?nilpotency=-1,..3 {'$or': [{'nilpotency': -1}, {'nilpotency': {'$lte': 3}}]}
    /EllipticCurve/?cm_disc=-4..-3 {'cm': {'$in': [-4, -3]}}
    /ModularForm/GL2/Q/Maass/?spectral_parameter=..9.6 {'spectral_parameter': {'$lte': 9.6...}}

    Controls n=2..8, conductor=11-30, discriminant=-3-5 unchanged; - and 3-- still flash errors.

  • Differential check over ~11,400 random and exhaustive strings: against the previous head, LIST_RE gains only ..-forms and loses only items containing a bare -; LIST_FLOAT_RE gains only ..-forms and loses nothing.

…ew fix)

The rewritten float item grammar still had two parses for one class of item:
a float may end in a decimal point and another may begin with one, so with ..
allowed between two endpoints, 1...2 parses both as 1. .. 2 and as 1 .. .2.
Rejecting a comma separated list of n such items was again exponential in n
("1...2," * 22 + "x" took 1.8 s).  No web request path reaches LIST_FLOAT_RE
with that token, since prep_ranges rewrites 1...2 to 1-.2 first, but the
pattern is module level and the claim that every item has one parse was wrong.

prep_ranges already normalizes an internal .. to a dash before either pattern
runs, so .. does not need to be a separator between two endpoints at all; only
the leading .. of an omitted lower endpoint reaches them.  Drop the alternative
from both item grammars, keeping the dedicated leading .. branch, and cover
1...2 (plus 1.-.2) in the timeout test.

A sweep over every string of length at most 6 that either pattern accepts as an
item now reports no item whose repetition takes more than 36 microseconds to
reject at 26 copies; the same sweep flags 0...0, 0...1, 1...0 and 1...1 at 29
seconds each on the previous commit.  The other list patterns in this file
(LIST_POSINT_RE, LIST_RAT_RE, QQ_LIST_RE, SIGNED_LIST_RE, MULTISET_RE) are
clean under the same sweep.

1..5 and ..5 keep working through parse_ints and parse_floats; a new test
pins that, since the patterns themselves now reject 1..5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Owner Author

Confirmed, and fixed in 4ff6314fa. "1...2," * 22 + "x" took 1.81 s at c95b259e, doubling per item — the review is right that my "exactly one parse per item" claim was false, and my earlier timing check used hand-picked units that never included this shape.

The fix

Took the first suggested option. prep_ranges already normalizes an internal .. to a dash before either pattern runs, so .. never needs to separate two endpoints; only the leading .. of an omitted lower endpoint reaches them. Dropped the alternative from both item grammars (and the now-pointless RANGE_SEP_STR), keeping the dedicated leading-.. branch:

INT_ITEM_STR   = r"(?:-?\d+(?:-(?:-?\d+)?)?|--\d+|\.\.-?\d+)"
FLOAT_ITEM_STR = r"(?:FLOAT(?:-(?:FLOAT)?)?|--UNSIGNED_FLOAT|\.\.FLOAT)"

I applied it to LIST_RE too. The integer grammar was never ambiguous — it has no decimal points — but there is no reason for it to accept a separator that preprocessing has already removed, and keeping the two grammars parallel makes the invariant easier to state.

The raw patterns therefore now reject 1..5, 2..10, -4..-1 and 5.., which they only ever saw post-prep_ranges anyway. New test_dotted_ranges_still_work_through_the_parsers pins that both spellings still reach the intended query through the public parsers, as asked:

assert not LIST_RE.match("1..5")
parse_ints({"c": "1..5"}, query, "c")    # {"c": {"$gte": 1, "$lte": 5}}
parse_ints({"c": "..5"}, query, "c")     # {"c": {"$lte": 5}}
parse_floats({"c": "1..5"}, ...)         # $gte ≈ 1, $lte ≈ 5
parse_floats({"c": "..5"}, ...)          # $lte only

Better method, since hand-picked units were the actual failure

I replaced the ad-hoc timing spot-check with an exhaustive sweep: enumerate every string up to length 6 over 01.-/e (plus a length-4 pass over 01.-,/e29), keep the ones the pattern accepts as a single item, and time rejecting (item + ",") * 26 + "x". An item with two parses explodes; a unique-parse item stays flat.

  • On c95b259e it flags exactly 0...0, 0...1, 1...0, 1...1, at ~29 s each — so the detector does catch this class.
  • On 4ff6314fa: 3866 accepted float items and 706 int items tested, 0 flagged, worst case 36 µs.
  • Run over the file's other list patterns (LIST_POSINT_RE, LIST_RAT_RE, QQ_LIST_RE, SIGNED_LIST_RE, MULTISET_RE, BRACKETED_*): all clean. That claim was hand-picked units last time; now it is exhaustive.
  • Plus a structured sweep for anything too long for brute force — every {"", "-", "--", "..", "..-"} × {"1", ".2", "1.", "1/2", "1e-5", "1.2e-3", …} × {"", "-", "..", "-1", "--1", "-.2", "-1..2", …} combination, items up to 14 characters: 301 accepted, 0 flagged.

test_lists_reject_malformed_input_promptly now covers 9 (pattern, input) pairs — -1, --1, -1--1, 1...2 and 1.-.2 against both patterns — rather than only the negative-singleton class.

Verified

  • lmfdb/utils/test_search_parsing.py → 13 passed.
  • galois_groups, ecnf, maass_forms, number_fields → 68 passed.
  • pyflakes + ruff check --preview --select=E722 clean.
  • Test client re-run: every query dict is byte-identical to the previous commit — nilpotency=-1..3 / ..3 / -1,..3, n=2..8, cm_disc=-4..-3, spectral_parameter=-1..9.6 and ..9.6, the controls, and - / 3-- still flashing errors. Narrowing the patterns changed nothing a user can reach.

CI for c95b259e had got as far as green setup jobs with the shards still running when this went up; 4ff6314fa supersedes it.

@roed314

roed314 commented Aug 5, 2026

Copy link
Copy Markdown

GPT signed off.

@roed-math

Copy link
Copy Markdown
Owner Author

Superseded by LMFDB#7141, opened upstream from this same branch. Closing here; review continues upstream.

@roed-math roed-math closed this Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants