Repository navigation
fix(eval): parse_json(contains=True) bounds the characters its failed tries scan - #707
Conversation
… tries scan (intel#619) The contains scan capped the number of raw_decode tries at 1000, not the characters each try scans: '[' * 900 + '1,' * 100000 parsed the whole tail 900 times before it read as not JSON, which took seconds. The tries that fail now add the characters they scanned (the failure position minus the start) to a running total, and the scan stops with (False, None) once it passes CONTAINS_JSON_MAX_SCAN (1,000,000), as it does at the start cap. A RecursionError counts 0. CONTAINS_JSON_MAX_STARTS is unchanged, and a later start after a late-failing one is still found within the budget. The answer-formats doc and the changelog name the new cap. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
✅ OpenCodeReview: Review complete: 0 finding(s) across 3 selected item(s). CI at Review limit reached. This PR has had 6 reviews, so it will only be reviewed again when you ask. Review details
|
vlad-perevezentsev
left a comment
There was a problem hiding this comment.
Review at 475bf87. The fix works for the case it names: '[' * 999 + '1,' * 1_500_000 drops from 44 s to 0.1 s. But three kinds of failed tries are still charged almost nothing, so the same cost remains for other long answers.
Agreed with @exai-bot on all three: the unterminated string, the long integer and the RecursionError path. Measured numbers are in the replies.
What I ran, at 475bf87:
make ci-local --offline: 1 failed, 20 passed, 19 skipped. The failure isschema_compat:mainadded fields tonovelty.v1.jsonafter this branch's base (#633); this PR does not touch schemas.- Timings on the head and the base, 999 starts and a 3M-character tail: the PR's case 0.1 s vs 44 s; unterminated string 3.2 s vs 3.2 s; long integer 2.7 s vs 2.7 s; flat string then deep nesting 3.6 s vs 3.6 s.
Checked, no issues
- The new test: it fails on the base (no budget, 900 tries) and its call bound matches the code.
- Docs and changelog:
answer-formats.mdnames the new limit, and the fragment is under Fixed.
… position A try that raised ValueError (overlong integer) or RecursionError was charged 0, and an unterminated string was charged up to its opening quote, so starts x length cost remained. They are now charged to the end of the text. The docstring and changelog say the first whole-text parse is not counted, only tries that start at a bracket. Tests pin that a failed try is charged from its start and that the first parse is not charged. Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com>
|
Pushed 1 commit to
Threads: 3 fixed, 0 answered as not a defect. Questions for @antonwolfy1. Should an exhausted scan budget return
|
Settles the question of the review of intel#707 (whether an exhausted scan budget returns not scored as the regex check does): it stays not JSON. Returning not scored needs a signal out of parse_json, and the typical input that spends the budget is a deeply nested answer, which the start-cap fix of intel#619 and its tests read as not JSON with contains on; not scored would reverse that and leave a junk answer unscored. The changelog fragment now says such answers fail, with the repro, and a test pins the check result at the budget, at the depth limit and within the budget. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
…get fails
Settles the review point that six failed tries are "a few" and still read as not JSON: the fragment now says the value is found as before when the failed tries scan under the budget in all, and gives the reviewer's example (`'[' * 6 + '1,' * 100000 + ' {"a": 1}'`, six tries of 200,000 characters) as one that fails now. The test uses that same input.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
@vlad-perevezentsev (#707 (review)): The three review threads (long integer, depth limit, unterminated string) are answered in 4565022466, 4565023276 and 4565022850: charged in b99e2d1 and passing at the head. The not-scored question is answered in 4563462339. The |
@vchamarthi (#707 (comment)): Question 1: option B. An exhausted budget stays "not JSON" and the changelog says so, with your example (c6741e2, c0f22ca); the reasoning is in the reply to 4563462339. The three charged failure kinds from b99e2d1 are confirmed in the code and tests at the head. |
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #707 +/- ##
==========================================
- Coverage 95.73% 95.72% -0.01%
==========================================
Files 408 414 +6
Lines 54644 55315 +671
Branches 9404 9509 +105
==========================================
+ Hits 52311 52948 +637
- Misses 1442 1465 +23
- Partials 891 902 +11
🚀 New features to boost your workflow:
|
napetrov
left a comment
There was a problem hiding this comment.
Change class: not computed (no receipt for this repo in this checkout)
Verdict: comment only, no approval. One question on the budget design, flagged for a human decision. Head c0f22ca, merge base c8e707c, lane L1.
CI: 0 failing Actions jobs on head; the merge base is behind the base tip, no red to attribute.
Design (human call): how much budget a failed start may take, and whether a value after a long failed start must still be found. Not a one-way door, so no blocking finding. Thread C2 is the probed case; T4 is the 6-bracket case already answered.
Coverage: plain and edge pass on head and base; probe of the 1.1M-bracket input on both trees. Not run: pytest (regex not installed), ruff, mypy, Python 3.11 and 3.14 (only 3.12 here).
The answer-formats json row said the scan stops after the failed tries scanned 1,000,000 characters. A try that fails on an overlong number, the depth limit or an unterminated string is charged to the end of the text, as the changelog fragment says, so the row now says so too. Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
|
xaleryb
left a comment
There was a problem hiding this comment.
Re-checked on e9e2edf merged with main: every earlier cost input ends in about 0.01 s, and the json row in docs/answer-formats.md now says how a failed try without a position is charged, matching the code and the changelog; the full suite, ruff, mypy, bandit and docs check --strict pass, and CI is green.
Settles review thread 4568041537: a valid value after a start that hits the depth limit, followed by a long run of brackets, now reads as not JSON because that try is charged the rest of the text. The fragment names this input as part of the trade-off. The charge stays: a depth-limit try carries no position and can scan a long flat prefix first (1000 such tries took 10 s), so charging it nothing would bring the cost back. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
@napetrov (#707 (review)): The review body only carries the budget question of thread 4568041537, answered above. |
@xaleryb (#707 (review)): Approval; it recaps its checks. Nothing to change. The |
|
@xaleryb (#707 (comment)): The comment recaps its re-run and e9e2edf (the |
…ries reach the scan budget
Settles the review point on the strict `>` guard: failed tries that scanned exactly CONTAINS_JSON_MAX_SCAN characters left the budget "spent" in the docs but still let the next bracket be tried, so `'[1,' + ' ' * (CONTAINS_JSON_MAX_SCAN - 3) + '] {"a":1}'` returned the object. The guard is now `>=`, the docstring and changelog say that reaching the budget spends it and one try may cross it, and a test pins both sides of the boundary.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
Ready for another look at Declined, with the evidence in the reply:
|
Summary
parse_json(answer, contains=True)(#619) capped the number ofraw_decodetries atCONTAINS_JSON_MAX_STARTS(1000), not the characters each try scans. Onmain,parse_json('[' * 900 + '1,' * 100000, contains=True)returns(False, None)after about 2.3 s on Linux, because each of the 900 tries parses the whole tail before it fails at the end of the text.The issue offers two fixes; neither is taken. Stopping after the first try that consumed to the end would drop
'[ {"a": 1}, [1,', whose first[fails at the end while the object after it is valid JSON (test_parse_json_contains_finds_a_value_after_a_start_too_deep_to_decodepins that a later start is still found). A window per try would refuse a valid long value. Instead the total work is bounded: each failed try adds the characters it scanned (theJSONDecodeError.posminus its start) to a running total, and the scan reads the text as not JSON once the total passesCONTAINS_JSON_MAX_SCAN(1,000,000), as it does at the start cap.Closes #619
Changes
dokimi/eval/reference_checks.py: new constantCONTAINS_JSON_MAX_SCAN; thecontainsloop inparse_jsonsums the scanned characters of failed tries and stops once they reach the constant (the try that reaches it may cross it). A try that raisesValueError(an overlong integer) orRecursionError(the depth limit) carries no position and is charged the rest of the text, and an unterminated string, which reports its opening quote, is charged to the end of the text. A scan that gives up reads as not JSON, and the changelog fragment says so, with the input'[' * 6 + '1,' * 100000 + ' {"a": 1}', which finds the object onmainand fails now.CONTAINS_JSON_MAX_STARTSand its behaviour are unchanged. The docstring and the constants' comment name the new cap.docs/answer-formats.md: thejsonrow names the scan budget next to the 1000-bracket limit.tests/test_fuzz_targets.py: the issue's repro returns(False, None)withraw_decodecalled at most 7 times (a spy, no timing); a value after late-failing starts within the budget is still found; failures without a position (overlong integer, depth limit, unterminated string) are charged and bounded the same way.tests/test_reference_checks.py: ajsoncheck withcontainswhose scan gives up fails asnot JSON(it is not left unscored as aregexcheck past its limit is), and the same shape within the budget passes.raw_decodeis called from this one site in the package; no other loop decodes from many start offsets.Risk
A text whose failed tries scan 1,000,000 characters or more in all before the first valid value now reads as not JSON, where it was found (slowly) before; six brackets followed by 200,000 characters and then an object are enough (
'[' * 6 + '1,' * 100000 + ' {"a": 1}'). The check fails asnot JSON; it is not left unscored. The same holds for a value before a long run of brackets: a try that hits the depth limit carries no position and is charged the rest of the text, so'[' + '{"a": 1},' + '[' * 1_100_000reads as not JSON (the object is found onmain); charging such a try nothing would bring the cost back, since 1000 depth-limit tries that each scan a 1,000,000-character flat prefix took 10 s. A found value of any length when no earlier try failed late, and a value after stray brackets whose failed tries stay under the budget, are unchanged. Open PR #472 also editseval/reference_checks.py; this change touches only the constants and thecontainsloop ofparse_json, so a conflict is possible only if #472 edits those lines.Testing
On a Linux review host, at
63b6a04(onmainc1d7f5a):make ci-local: 30 passed, 2 failed, 14 skipped (docker and network steps, micromamba, semgrep, the Intel CI matrix, fuzz, the other Pythons). The 2 failures are the gitleaks download (HTTP 504) and the commit scan that needs it. 19959 tests passed, 19 skipped.parse_json('[' + '{"a":1},' + '[' * 1_100_000, contains=True)returns(False, None)at the head (the object is found onmain); the commit of this round changes only the changelog fragment, so it has no mutant.mainparse_json('[' * 900 + '1,' * 100000, contains=True)takes 2.25 s and callsraw_decode901 times; here the new test sees at most 7 calls.test_parse_json_contains_bounds_the_characters_failed_tries_scan(901 calls); the test also fails onmain's code (the constant does not exist there).parse_json('[' * 6 + '1,' * 100000 + ' {"a": 1}', contains=True)returns(False, None)at the head (it finds the object onmain); the newtest_a_contains_json_scan_that_gives_up_is_failed_not_unscoredpins the check resultnot JSON. Mutants: raising the budget to 10**12 fails that test at its first assertion; a budget of 0 fails it at the within-budget assertion.parse_json('[1' + ' ' * (CONTAINS_JSON_MAX_SCAN - 2) + '2] {"a":1}', contains=True)(the first try fails at position 1,000,000, charging exactly the budget) returns(False, None)at the head and(True, {'a': 1})with the guard as>; the same text one space shorter finds the object.test_parse_json_contains_stops_when_the_failed_tries_reach_the_budget_exactlypins both. Mutant: the guard back to>fails it at the first assertion.Revert
A plain revert restores the old behaviour (the start cap alone). Nothing is stored.
Checklist
pytestpassesruff,mypy, and schema checks passAGENTS.md.github/.licenserc.yaml)