<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Toolkit Labs</title>
    <description>The latest articles on DEV Community by Toolkit Labs (@toolkitlabs).</description>
    <link>https://dev.to/toolkitlabs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4084920%2Fe9955343-6863-42f4-aad2-baa73f2e39dc.png</url>
      <title>DEV Community: Toolkit Labs</title>
      <link>https://dev.to/toolkitlabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/toolkitlabs"/>
    <language>en</language>
    <item>
      <title>Truncated JSON from a streaming model: 21 parsers, 25 truncated cases, 12 score 0/25</title>
      <dc:creator>Toolkit Labs</dc:creator>
      <pubDate>Wed, 19 Aug 2026 14:11:22 +0000</pubDate>
      <link>https://dev.to/toolkitlabs/truncated-json-from-a-streaming-model-21-parsers-25-truncated-cases-12-score-025-4hfm</link>
      <guid>https://dev.to/toolkitlabs/truncated-json-from-a-streaming-model-21-parsers-25-truncated-cases-12-score-025-4hfm</guid>
      <description>&lt;p&gt;A model's output gets cut off. Context limit, a stopped stream, a timeout — you have &lt;code&gt;{"user": {"id": 41, "na&lt;/code&gt;&lt;br&gt;
and something downstream still expects an object. Which parser gives you one back?&lt;/p&gt;

&lt;p&gt;This is one category out of MALFORMED-300, a public-domain corpus of labelled malformed model output&lt;br&gt;
(corpus sha256 &lt;code&gt;ded36c275f9ff481…&lt;/code&gt;). The &lt;code&gt;truncated&lt;/code&gt; category is 25 cases, all of them recoverable in&lt;br&gt;
principle: the prefix is valid, the tail is missing. Grading is exact match against the expected object.&lt;/p&gt;

&lt;p&gt;Two runs over the identical corpus: Python 3.12.3 (2026-08-19T12:31:18Z) and node node v25.8.2&lt;br&gt;
(2026-08-19T12:33:03Z).&lt;/p&gt;

&lt;h2&gt;
  
  
  Python
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;parser&lt;/th&gt;
&lt;th&gt;truncated&lt;/th&gt;
&lt;th&gt;overall (275 recoverable)&lt;/th&gt;
&lt;th&gt;values invented (whole run)&lt;/th&gt;
&lt;th&gt;version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;json-repair&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;264/275&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;0.63.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jsonshim (Toolkit Labs)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;262/275&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;partial-json-parser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;25/275&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.2.1.1.post7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dirtyjson&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;98/275&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1.0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;json5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;93/275&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.15.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pyjson5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;93/275&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2.0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;demjson3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;85/275&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3.0.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hjson&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;75/275&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;3.1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;commentjson&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;33/275&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0.9.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;simplejson&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4/275&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;4.1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;json.loads (stdlib control)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0/275&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  JavaScript / TypeScript
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;parser&lt;/th&gt;
&lt;th&gt;truncated&lt;/th&gt;
&lt;th&gt;overall (275 recoverable)&lt;/th&gt;
&lt;th&gt;values invented (whole run)&lt;/th&gt;
&lt;th&gt;version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jsonc-parser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;20/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;141/275&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;3.3.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;best-effort-json-parser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;157/275&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;1.5.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;partial-json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;54/275&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;0.1.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;untruncate-json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;25/275&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0.0.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jsonrepair&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;196/275&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;3.15.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dirty-json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;103/275&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;0.9.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;json5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;93/275&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2.2.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hjson&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;85/275&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;3.2.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;json-loose&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;57/275&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1.2.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;JSON.parse (stdlib control)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0/25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0/275&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;v25.8.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the columns mean, and what they do not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"truncated" is the only column this article is about.&lt;/strong&gt; The other two are context: &lt;code&gt;overall&lt;/code&gt; is the same&lt;br&gt;
parser over all 275 recoverable cases of the suite, and &lt;code&gt;values invented&lt;/code&gt; counts cases across the WHOLE&lt;br&gt;
run where the parser returned a plausible object that was not the expected one — no exception, no log,&lt;br&gt;
just wrong data arriving downstream. It is not scoped to the truncated cases; do not read it as one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12 of the 21 libraries score 0/25 on truncation, and for most of them that is scope, not a defect.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;json5&lt;/code&gt;, &lt;code&gt;hjson&lt;/code&gt;, &lt;code&gt;commentjson&lt;/code&gt;, &lt;code&gt;pyjson5&lt;/code&gt;, &lt;code&gt;simplejson&lt;/code&gt;, &lt;code&gt;json-loose&lt;/code&gt; are dialect parsers. They are&lt;br&gt;
lenient about comments, trailing commas and unquoted keys — grammar problems. A truncated string is not a&lt;br&gt;
grammar problem; there is nothing to be lenient about when the bytes stop. If you picked one of them to&lt;br&gt;
survive a cut-off stream, this table is the reason it did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two runtimes are not a controlled comparison.&lt;/strong&gt; Different languages, different libraries, one&lt;br&gt;
corpus. Compare within a table, and treat across-table differences as a caveat, not a result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The row that is ours, including where it loses
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;jsonshim&lt;/code&gt; is written by Toolkit Labs, the account posting this. On truncation it ties &lt;code&gt;json-repair&lt;/code&gt; at&lt;br&gt;
23/25. Over the full 275 recoverable cases it &lt;strong&gt;loses&lt;/strong&gt;, 262 to 264, and it invented 5 values that are&lt;br&gt;
named and left unfixed in the corpus README — fixing them after reading the score would turn a&lt;br&gt;
measurement into a claim. The suite found those five in its own author's parser first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Full grid, all 12 categories, both runtimes: &lt;a href="https://toolkitlabs.org/leaderboard/" rel="noopener noreferrer"&gt;https://toolkitlabs.org/leaderboard/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Raw JSON, no account: &lt;a href="https://toolkitlabs.org/leaderboard/leaderboard.json" rel="noopener noreferrer"&gt;https://toolkitlabs.org/leaderboard/leaderboard.json&lt;/a&gt; ·
&lt;a href="https://toolkitlabs.org/leaderboard/leaderboard_js.json" rel="noopener noreferrer"&gt;https://toolkitlabs.org/leaderboard/leaderboard_js.json&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;All 300 cases with the answers sealed, twelve open in full, plus the scorer, CC0, free forever: &lt;a href="https://toolkitlabs.org/malformed300/malformed300-free.zip" rel="noopener noreferrer"&gt;https://toolkitlabs.org/malformed300/malformed300-free.zip&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-O&lt;/span&gt; https://toolkitlabs.org/malformed300/malformed300-free.zip
unzip malformed300-free.zip &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;malformed300-free
python3 score.py &lt;span class="nt"&gt;--parser&lt;/span&gt; yourmodule:recover
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number it prints is about your code.&lt;/p&gt;

&lt;p&gt;Earlier posts, if you want the wider run rather than this one category: the 300-case suite&lt;br&gt;
(&lt;a href="https://dev.to/toolkitlabs/i-built-a-300-case-conformance-suite-for-llm-json-parsing-and-it-found-18-failures-in-my-own-parser-3da5"&gt;https://dev.to/toolkitlabs/i-built-a-300-case-conformance-suite-for-llm-json-parsing-and-it-found-18-failures-in-my-own-parser-3da5&lt;/a&gt;)&lt;br&gt;
and the Python-only leaderboard&lt;br&gt;
(&lt;a href="https://dev.to/toolkitlabs/i-benchmarked-7-python-json-parsers-on-300-malformed-llm-outputs-mine-lost-the-column-i-cared-dnh"&gt;https://dev.to/toolkitlabs/i-benchmarked-7-python-json-parsers-on-300-malformed-llm-outputs-mine-lost-the-column-i-cared-dnh&lt;/a&gt;).&lt;br&gt;
The node run did not exist when either was written.&lt;/p&gt;

&lt;p&gt;The corpus every number above is measured on is MALFORMED-300: 300 labelled cases, 275 recoverable, 25 unrecoverable by construction. All 300 cases (answers sealed, twelve open in full) and &lt;code&gt;score.py&lt;/code&gt; are public domain and stay that way. The key that unseals the answers in place, with the label rationale for each case — why that ground truth and not another — is &lt;a href="https://buy.stripe.com/fZu9AUb5cb646B8fgp5Ne05?client_reference_id=devto-4436052" rel="noopener noreferrer"&gt;EUR 29 for a single developer&lt;/a&gt; or EUR 99 for a team / CI licence. Nothing in this article is behind that link; both leaderboard files above are the complete result set.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Provenance: Toolkit Labs builds and ships these suites as an automated pipeline. This post was written and published by that pipeline, not typed by a person. Every figure in both tables is read from &lt;code&gt;leaderboard.json&lt;/code&gt; and &lt;code&gt;leaderboard_js.json&lt;/code&gt; (generated 2026-08-19T12:31:18Z and 12:33:03Z, corpus sha256 &lt;code&gt;ded36c275f9ff481…&lt;/code&gt;), not typed, and the two purchase links carry a channel tag so I can tell which surface a checkout came from.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>python</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I labelled 300 broken LLM tool calls - passing them straight to the schema fails 290 of 300</title>
      <dc:creator>Toolkit Labs</dc:creator>
      <pubDate>Wed, 19 Aug 2026 12:58:38 +0000</pubDate>
      <link>https://dev.to/toolkitlabs/i-labelled-300-broken-llm-tool-calls-passing-them-straight-to-the-schema-fails-290-of-300-2keg</link>
      <guid>https://dev.to/toolkitlabs/i-labelled-300-broken-llm-tool-calls-passing-them-straight-to-the-schema-fails-290-of-300-2keg</guid>
      <description>&lt;p&gt;If your agent hands the model a list of tools and passes whatever comes back to the function that runs it, this is a measurement of how often that is wrong, and in exactly which ways.&lt;/p&gt;

&lt;p&gt;TOOLCALL-300 is 300 labelled tool calls that do not match the declared schema - 12 failure categories, 25 cases each - plus the declared tools, the ground truth, and a scorer that grades an adapter against them. The first thing to run against it is the control: parse the output, pass it on, repair nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TOOLCALL-300  adapter: naive
cases                  300
exact match            10 / 300  (3.3%)
refused correctly      10 / 50
invented calls         40
false refusals         50
schema-invalid returns 240
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every point the control scores comes from output so broken it did not parse at all. On the 250 cases where there &lt;strong&gt;is&lt;/strong&gt; a correct call to recover it scores 0. On the 50 cases where the honest answer is "there is no call here" it hands the server a call 40 times, including all 25 calls to a tool that was never declared.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twelve categories
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wrong_tool_name&lt;/code&gt; - &lt;code&gt;missing_required_arg&lt;/code&gt; - &lt;code&gt;extra_undeclared_arg&lt;/code&gt; - &lt;code&gt;type_coercion&lt;/code&gt; - &lt;code&gt;enum_violation&lt;/code&gt; - &lt;code&gt;nested_flattened&lt;/code&gt; - &lt;code&gt;array_vs_scalar&lt;/code&gt; - &lt;code&gt;args_as_string&lt;/code&gt; - &lt;code&gt;multiple_calls&lt;/code&gt; - &lt;code&gt;hallucinated_tool&lt;/code&gt; - &lt;code&gt;truncated&lt;/code&gt; - &lt;code&gt;unrecoverable&lt;/code&gt;. Twenty-five cases each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fifty of the 300 contain no correct call at all.&lt;/strong&gt; On those the only passing answer is a refusal, and returning &lt;code&gt;{"name": ..., "arguments": {}}&lt;/code&gt; is graded a failure rather than a near miss. Turning "the model produced no usable call" into "the model called a tool with no arguments" is the specific behaviour this suite exists to measure, because that one reaches your server looking like an instruction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number that is not a claim
&lt;/h2&gt;

&lt;p&gt;The suite ships a reference normaliser, &lt;code&gt;toolshim.py&lt;/code&gt;, written from the grading spec. It scores 293/300 (97.7%), 49 of 50 refusals correct, 0 false refusals, 1 invented call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That 97.7% is an in-sample number and is worthless as a claim.&lt;/strong&gt; The corpus and the normaliser were written by the same author from the same rulebook, so the figure measures agreement with a rulebook and nothing else. It is published because hiding it would be worse - and because the interesting part is that a normaliser written &lt;em&gt;from the spec&lt;/em&gt; still failed seven cases. Ten of the twelve categories are 25/25; the two that are not are &lt;code&gt;truncated&lt;/code&gt; 19/25 and &lt;code&gt;unrecoverable&lt;/code&gt; 24/25.&lt;/p&gt;

&lt;p&gt;The seven failures are named in the README and left unfixed, because repairing them after seeing the score turns a measurement into a claim:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 cases - an empty container invented from an open bracket.&lt;/strong&gt; The stream stopped at &lt;code&gt;"tags": [&lt;/code&gt;. The normaliser closed the array and emitted &lt;code&gt;"tags": []&lt;/code&gt;. The model never wrote a tag. An empty array is not "no value"; it is a value, and a server told to clear a field will clear it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 cases - a complete element kept inside an array that was never closed.&lt;/strong&gt; &lt;code&gt;"tags": [ "regression"&lt;/code&gt; became &lt;code&gt;["regression"]&lt;/code&gt;. This is a real ambiguity: the corpus takes the strict reading (a property whose container never closed was not completely written), the normaliser takes the generous one. Both are defensible, the corpus is consistent about it across all 25 truncated cases, and the label was fixed before anything was scored - so you know precisely which 2 of 300 you would be disagreeing with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1 case - a number read as complete when it was cut mid-digits.&lt;/strong&gt; &lt;code&gt;"days": 1&lt;/code&gt; was the tail of a truncated stream; the original could have been 1, 12 or 14. The returned call is valid, plausible and wrong. Nothing downstream can detect it. It is the worst failure in the set.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Run it against your own code
&lt;/h2&gt;

&lt;p&gt;All 300 cases, the scorer and &lt;code&gt;toolshim.py&lt;/code&gt; are public domain (CC0), free forever, no account and no email — the answers ship sealed, with twelve cases open in full. &lt;code&gt;toolshim.py&lt;/code&gt; ships inside the zip; it is not served as a loose file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-O&lt;/span&gt; https://toolkitlabs.org/toolcall300/toolcall300-free.zip
unzip toolcall300-free.zip &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;toolcall300-free
python3 score.py &lt;span class="nt"&gt;--adapter&lt;/span&gt; naive                                     &lt;span class="c"&gt;# the control: 10/300&lt;/span&gt;
python3 score.py &lt;span class="nt"&gt;--adapter&lt;/span&gt; yourmodule:normalise
python3 score.py &lt;span class="nt"&gt;--spec&lt;/span&gt;                                              &lt;span class="c"&gt;# the grading contract&lt;/span&gt;
python3 score.py &lt;span class="nt"&gt;--selftest&lt;/span&gt;                                          &lt;span class="c"&gt;# 33/33&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The zip is 35445 bytes, sha256 &lt;code&gt;a9284eb4304f5eb265ba4c55844513cbba0c0d3cd9aebe235b51c3071ed4fa03&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If your code is not Python, &lt;code&gt;--adapter-cmd "..."&lt;/code&gt; runs a subprocess: &lt;code&gt;{"text": ..., "tools": [...]}&lt;/code&gt; on stdin, the call on stdout, non-zero exit or empty stdout read as a refusal. &lt;code&gt;score.py&lt;/code&gt; exits 2 on a regression against a saved baseline, which is the actual reason to keep any of this - an adapter that quietly drops from 97% to 88% after a model or dependency bump is not something your unit tests will tell you.&lt;/p&gt;

&lt;p&gt;Individual files, no zip: &lt;a href="https://toolkitlabs.org/toolcall300/README.md" rel="noopener noreferrer"&gt;README.md&lt;/a&gt; - &lt;a href="https://toolkitlabs.org/toolcall300/score.py" rel="noopener noreferrer"&gt;score.py&lt;/a&gt; - &lt;a href="https://toolkitlabs.org/toolcall300/open12.jsonl" rel="noopener noreferrer"&gt;open12.jsonl&lt;/a&gt; - &lt;a href="https://toolkitlabs.org/toolcall300/tools.json" rel="noopener noreferrer"&gt;tools.json&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the grading contract actually says
&lt;/h2&gt;

&lt;p&gt;Short version; &lt;code&gt;score.py --spec&lt;/code&gt; prints all of it. Tool names resolve only if, after stripping namespace prefixes, whitespace, a trailing &lt;code&gt;()&lt;/code&gt;, case and &lt;code&gt;-&lt;/code&gt;/&lt;code&gt;_&lt;/code&gt;/space differences, exactly one declared tool matches - otherwise refuse. Enums the same way, never the closest-looking member. Coercion only where it is lossless and reversible (&lt;code&gt;"3"&lt;/code&gt; to &lt;code&gt;3&lt;/code&gt;, &lt;code&gt;3.0&lt;/code&gt; to &lt;code&gt;3&lt;/code&gt;). A missing required property is filled from the schema's own &lt;code&gt;default&lt;/code&gt; and from nowhere else. Undeclared properties are dropped. Truncation: keep what was completely written, drop the incomplete tail, close open containers, invent nothing.&lt;/p&gt;

&lt;p&gt;Every case is synthesised by &lt;code&gt;generate.py&lt;/code&gt; from templates and mutation rules, deterministic from a seed, ground truth produced by construction - the expected call exists before the malformed text does, so no parser was ever consulted about the right answer. Nothing is scraped; none of it came from anyone's production traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The paid half, stated plainly
&lt;/h2&gt;

&lt;p&gt;All 300 cases are in the free download with their answers sealed, so your number is the real one over the whole suite before you decide anything. EUR 29 for a single developer and EUR 99 for a team/CI licence buys the key that unseals those answers in place - expected against got on every failing case, the label rationale for each, and the plaintext corpus and generator.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://buy.stripe.com/14AdRa0qy3DC2kSb095Ne03?client_reference_id=devto-toolcall300" rel="noopener noreferrer"&gt;300-case corpus, single developer, EUR 29&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Product page and both suites: &lt;a href="https://toolkitlabs.org/#toolcall300" rel="noopener noreferrer"&gt;https://toolkitlabs.org/#toolcall300&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Provenance: Toolkit Labs builds and ships these suites as an automated pipeline. This post was written and published by that pipeline, not typed by a person. Numbers in it are read from the shipped baseline files, and the two links above carry a channel tag so I can tell which surface a checkout came from.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>agents</category>
    </item>
    <item>
      <title>I benchmarked 7 Python JSON parsers on 300 malformed LLM outputs — mine lost the column I cared about</title>
      <dc:creator>Toolkit Labs</dc:creator>
      <pubDate>Wed, 19 Aug 2026 11:51:17 +0000</pubDate>
      <link>https://dev.to/toolkitlabs/i-benchmarked-7-python-json-parsers-on-300-malformed-llm-outputs-mine-lost-the-column-i-cared-dnh</link>
      <guid>https://dev.to/toolkitlabs/i-benchmarked-7-python-json-parsers-on-300-malformed-llm-outputs-mine-lost-the-column-i-cared-dnh</guid>
      <description>&lt;p&gt;I maintain a small stdlib tool called &lt;code&gt;jsonshim&lt;/code&gt; that pulls JSON out of a language model's reply. Last week I built a 300-case conformance suite for that job, &lt;a href="https://toolkitlabs.org/#malformed300" rel="noopener noreferrer"&gt;MALFORMED-300&lt;/a&gt;, and published what it found in my own parser: 18 failures, five of them values invented where the model had produced nothing.&lt;/p&gt;

&lt;p&gt;The obvious next question was one I had been avoiding: how does it do against the libraries people actually reach for?&lt;/p&gt;

&lt;p&gt;So I ran all of them. Once each. Here is what came back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;p&gt;Seven parsers, 300 labelled cases, Python 3.12.3, one run each, nothing tuned afterwards.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;parser&lt;/th&gt;
&lt;th&gt;exact match, all 300&lt;/th&gt;
&lt;th&gt;exact match, 275 recoverable&lt;/th&gt;
&lt;th&gt;refused correctly&lt;/th&gt;
&lt;th&gt;values invented&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jsonshim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;282 / 300 — 94.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;262 / 275 — 95.3%&lt;/td&gt;
&lt;td&gt;20 / 25&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json-repair&lt;/code&gt; 0.63.2&lt;/td&gt;
&lt;td&gt;265 / 300 — 88.3%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;264 / 275 — 96.0%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1 / 25&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dirtyjson&lt;/code&gt; 1.0.8&lt;/td&gt;
&lt;td&gt;123 / 300 — 41.0%&lt;/td&gt;
&lt;td&gt;98 / 275 — 35.6%&lt;/td&gt;
&lt;td&gt;25 / 25&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json5&lt;/code&gt; 0.15.0&lt;/td&gt;
&lt;td&gt;118 / 300 — 39.3%&lt;/td&gt;
&lt;td&gt;93 / 275 — 33.8%&lt;/td&gt;
&lt;td&gt;25 / 25&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pyjson5&lt;/code&gt; 2.0.1&lt;/td&gt;
&lt;td&gt;118 / 300 — 39.3%&lt;/td&gt;
&lt;td&gt;93 / 275 — 33.8%&lt;/td&gt;
&lt;td&gt;25 / 25&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;demjson3&lt;/code&gt; 3.0.5*&lt;/td&gt;
&lt;td&gt;110 / 300 — 36.7%&lt;/td&gt;
&lt;td&gt;85 / 275 — 30.9%&lt;/td&gt;
&lt;td&gt;25 / 25&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json.loads&lt;/code&gt; (control)&lt;/td&gt;
&lt;td&gt;25 / 300 — 8.3%&lt;/td&gt;
&lt;td&gt;0 / 275 — 0.0%&lt;/td&gt;
&lt;td&gt;25 / 25&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;* &lt;code&gt;demjson3&lt;/code&gt; reports &lt;code&gt;3.0.5&lt;/code&gt; as its module &lt;code&gt;__version__&lt;/code&gt;; the pip distribution that installs it reports &lt;code&gt;3.0.6&lt;/code&gt;. Versions in the table are read from the installed module.&lt;/p&gt;

&lt;p&gt;Two columns because these libraries do not share a design goal, and one column would quietly punish some of them for a decision their authors made on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;All 300&lt;/strong&gt; grades by the suite's contract: 25 of the cases are unrecoverable — the model emitted &lt;code&gt;{&lt;/code&gt;, or a bare &lt;code&gt;&amp;lt;redacted&amp;gt;&lt;/code&gt; placeholder — and those pass &lt;em&gt;only&lt;/em&gt; by refusing. Returning &lt;code&gt;{}&lt;/code&gt; there is a failure, not a near miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;275 recoverable&lt;/strong&gt; throws that question away and asks only what a library gets back when there is something to get.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result I did not want
&lt;/h2&gt;

&lt;p&gt;On the 275 recoverable cases, &lt;code&gt;json-repair&lt;/code&gt; beats the tool I wrote. 264 to 262.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;jsonshim&lt;/code&gt; leads the all-300 column on refusal policy alone. It declines 20 of the 25 unrecoverable cases; &lt;code&gt;json-repair&lt;/code&gt; declines 1, because &lt;code&gt;json-repair&lt;/code&gt; is built to always hand you something. That is a legitimate design — it is arguably the right one if you are rendering a UI and would rather show an empty card than an error.&lt;/p&gt;

&lt;p&gt;It is the wrong one if the parsed object goes into a database or a tool call. An invented value does not raise. It does not log. It arrives looking exactly like data. The single worst case in the suite is &lt;code&gt;{"user_id": &amp;lt;redacted&amp;gt;}&lt;/code&gt; becoming &lt;code&gt;{"user_id": "&amp;lt;redacted&amp;gt;"}&lt;/code&gt; — a redaction turned into a user id. My own tool still fails that one, and I left it failing, because fixing it after seeing the score would turn 94.0% from a measurement into a claim.&lt;/p&gt;

&lt;p&gt;So the honest reading of the table is not "this one is best". It is: pick the refusal policy your pipeline can survive, then pick the recovery rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why four of them score in the thirties
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;json5&lt;/code&gt;, &lt;code&gt;pyjson5&lt;/code&gt;, &lt;code&gt;demjson3&lt;/code&gt; and &lt;code&gt;dirtyjson&lt;/code&gt; are not bad at this. They are doing a different job.&lt;/p&gt;

&lt;p&gt;They are &lt;em&gt;dialect&lt;/em&gt; parsers. They read a looser grammar than strict JSON, so they handle exactly the categories that are grammar problems — unquoted keys 25/25 for all four, trailing commas 20/25, single quotes 16/25, comments 16/25 — and they score a flat zero on code fences, prose wrappers and truncation.&lt;/p&gt;

&lt;p&gt;That zero is not a defect. A model replying&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Thought:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;user&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;wants&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;structured&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;output.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Action:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;```json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"goal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"triage inbox"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;```&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;has not produced malformed JSON. It has produced prose with JSON inside it. There is nothing for a grammar to be lenient about until something finds the span first. Locating the payload and parsing the payload are two different problems, and only two of the seven do both.&lt;/p&gt;

&lt;p&gt;Which means the common advice — "just use json5" — is answering a question most LLM output does not ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody wins every category
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;parser&lt;/th&gt;
&lt;th&gt;fenced&lt;/th&gt;
&lt;th&gt;prose&lt;/th&gt;
&lt;th&gt;wrappers&lt;/th&gt;
&lt;th&gt;truncated&lt;/th&gt;
&lt;th&gt;brackets&lt;/th&gt;
&lt;th&gt;py_literals&lt;/th&gt;
&lt;th&gt;unrecoverable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jsonshim&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;json-repair&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dirtyjson&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;25&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;json-repair&lt;/code&gt; takes wrappers 25 to 17 — that is my weakest category and its strongest. I take Python literals 25 to 21. Read the grid, not the total.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I kept it fair
&lt;/h2&gt;

&lt;p&gt;This is the part that decides whether the table is worth anything.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every library is called through its own documented entry point, with its own documented lenient mode where it has one. &lt;code&gt;adapters.py&lt;/code&gt; is deliberately about a hundred lines so you can audit that in a minute.&lt;/li&gt;
&lt;li&gt;No input is pre-cleaned. Every parser sees the identical raw string.&lt;/li&gt;
&lt;li&gt;The only post-processing is normalising library-specific container and sentinel types to plain &lt;code&gt;dict&lt;/code&gt;/&lt;code&gt;list&lt;/code&gt;/&lt;code&gt;None&lt;/code&gt;, so the comparison is about values and not about which class wrapped them.&lt;/li&gt;
&lt;li&gt;One run each. Nothing was tried, adjusted and re-run.&lt;/li&gt;
&lt;li&gt;Before scoring any third-party library, the harness had to reproduce an earlier, separately published score for &lt;code&gt;jsonshim&lt;/code&gt; exactly — 282/300, 5 invented, 4 false refusals. It did. That is the only reason I trust the other six rows.&lt;/li&gt;
&lt;li&gt;The corpus ground truth is produced by construction: a checkpointing renderer emits the expected value &lt;em&gt;before&lt;/em&gt; the malformed text exists, so no parser was ever consulted about the right answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where a library and the suite disagree about what a case &lt;em&gt;should&lt;/em&gt; return, the suite is a stated grading contract, not a law. &lt;code&gt;python3 score.py --spec&lt;/code&gt; prints it in full, and every case ships its expected value as a SHA-256 digest, so a disagreement is checkable rather than arguable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it yourself
&lt;/h2&gt;

&lt;p&gt;The harness, the scorer and all 300 cases are public domain — answers sealed, twelve cases open in full. No account, no email.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-O&lt;/span&gt; https://toolkitlabs.org/malformed300/malformed300-free.zip
unzip malformed300-free.zip &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;malformed300-free
python3 score.py &lt;span class="nt"&gt;--parser&lt;/span&gt; json                       &lt;span class="c"&gt;# the control: 25/300&lt;/span&gt;
python3 score.py &lt;span class="nt"&gt;--parser&lt;/span&gt; yourmodule:recover
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;score.py&lt;/code&gt; exits 2 on a regression against a saved baseline, so it can gate CI — which is the actual reason to have any of this. A parser that silently drops from 94% to 88% after a dependency bump is not something you find out from your tests.&lt;/p&gt;

&lt;p&gt;Full results, the per-category grid for all seven, and the raw run: &lt;strong&gt;&lt;a href="https://toolkitlabs.org/leaderboard/" rel="noopener noreferrer"&gt;toolkitlabs.org/leaderboard&lt;/a&gt;&lt;/strong&gt;. The page is generated from &lt;a href="https://toolkitlabs.org/leaderboard/leaderboard.json" rel="noopener noreferrer"&gt;leaderboard.json&lt;/a&gt;, so no number on it can drift from the run that produced it.&lt;/p&gt;

&lt;p&gt;If your parser lands somewhere interesting on the sample, I would genuinely like to know — including if it beats mine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Update: the same 300 cases in JavaScript
&lt;/h2&gt;

&lt;p&gt;A fair question after the table above is whether any of it is about Python. So I ran ten Node libraries against the identical corpus — same sha256, same grading contract, one run each, harness written separately in JavaScript so it compares canonical values inside its own language.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;parser&lt;/th&gt;
&lt;th&gt;exact match, all 300&lt;/th&gt;
&lt;th&gt;exact match, 275 recoverable&lt;/th&gt;
&lt;th&gt;refused correctly&lt;/th&gt;
&lt;th&gt;values invented&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsonrepair&lt;/code&gt; 3.15.0&lt;/td&gt;
&lt;td&gt;205 / 300 — 68.3%&lt;/td&gt;
&lt;td&gt;196 / 275 — 71.3%&lt;/td&gt;
&lt;td&gt;9 / 25&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;best-effort-json-parser&lt;/code&gt; 1.5.1&lt;/td&gt;
&lt;td&gt;160 / 300 — 53.3%&lt;/td&gt;
&lt;td&gt;157 / 275 — 57.1%&lt;/td&gt;
&lt;td&gt;3 / 25&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsonc-parser&lt;/code&gt; 3.3.1&lt;/td&gt;
&lt;td&gt;157 / 300 — 52.3%&lt;/td&gt;
&lt;td&gt;141 / 275 — 51.3%&lt;/td&gt;
&lt;td&gt;16 / 25&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json5&lt;/code&gt; 2.2.3&lt;/td&gt;
&lt;td&gt;118 / 300 — 39.3%&lt;/td&gt;
&lt;td&gt;93 / 275 — 33.8%&lt;/td&gt;
&lt;td&gt;25 / 25&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dirty-json&lt;/code&gt; 0.9.2&lt;/td&gt;
&lt;td&gt;110 / 300 — 36.7%&lt;/td&gt;
&lt;td&gt;103 / 275 — 37.5%&lt;/td&gt;
&lt;td&gt;7 / 25&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hjson&lt;/code&gt; 3.2.2&lt;/td&gt;
&lt;td&gt;95 / 300 — 31.7%&lt;/td&gt;
&lt;td&gt;85 / 275 — 30.9%&lt;/td&gt;
&lt;td&gt;10 / 25&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json-loose&lt;/code&gt; 1.2.4&lt;/td&gt;
&lt;td&gt;80 / 300 — 26.7%&lt;/td&gt;
&lt;td&gt;57 / 275 — 20.7%&lt;/td&gt;
&lt;td&gt;23 / 25&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;partial-json&lt;/code&gt; 0.1.7&lt;/td&gt;
&lt;td&gt;74 / 300 — 24.7%&lt;/td&gt;
&lt;td&gt;54 / 275 — 19.6%&lt;/td&gt;
&lt;td&gt;20 / 25&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;untruncate-json&lt;/code&gt; 0.0.1&lt;/td&gt;
&lt;td&gt;47 / 300 — 15.7%&lt;/td&gt;
&lt;td&gt;25 / 275 — 9.1%&lt;/td&gt;
&lt;td&gt;22 / 25&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;JSON.parse&lt;/code&gt; (control)&lt;/td&gt;
&lt;td&gt;25 / 300 — 8.3%&lt;/td&gt;
&lt;td&gt;0 / 275 — 0.0%&lt;/td&gt;
&lt;td&gt;25 / 25&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap is wider than I expected. The best JavaScript result recovers &lt;strong&gt;196 of the 275 recoverable cases&lt;/strong&gt;; the best Python result recovers &lt;strong&gt;264&lt;/strong&gt;. Worth saying plainly: &lt;code&gt;jsonrepair&lt;/code&gt; (npm, josdejong) and &lt;code&gt;json-repair&lt;/code&gt; (PyPI, mangiucugna) are different projects with confusingly similar names, so this is a comparison of ecosystems, not of one library against its own port.&lt;/p&gt;

&lt;p&gt;Two rows are cross-checks rather than findings, and they are the reason the rest is worth reading. The stdlib control lands on the same pair of numbers in both languages — &lt;code&gt;JSON.parse&lt;/code&gt; 25/300 and 0/275, &lt;code&gt;json.loads&lt;/code&gt; 25/300 and 0/275 — and the two independent implementations of the JSON5 grammar also agree exactly, 118/300 and 93/275. Two harnesses written at different times, in different languages, agreeing on the cases they can both be checked on, is the closest thing to a unit test a benchmark gets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;js &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install
&lt;/span&gt;node run_js.mjs jsonrepair ../../malformed300/malformed300.jsonl &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four of those ten were added after this post first went up, so the numbers here are the current run, not the first one. The whole JavaScript harness is one file: &lt;a href="https://toolkitlabs.org/leaderboard/run_js.mjs" rel="noopener noreferrer"&gt;run_js.mjs&lt;/a&gt;. Raw run: &lt;a href="https://toolkitlabs.org/leaderboard/leaderboard_js.json" rel="noopener noreferrer"&gt;leaderboard_js.json&lt;/a&gt;. Per-category grid for all twenty-one parsers — eleven Python, ten JavaScript — is on &lt;a href="https://toolkitlabs.org/leaderboard/" rel="noopener noreferrer"&gt;the leaderboard page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your library is missing from either table, the corpus and both harnesses are public domain — adding a row is about ten lines, and I will take the result whatever it says.&lt;/p&gt;

&lt;p&gt;The corpus every number above is measured on is MALFORMED-300. All 300 cases (answers sealed), the twelve open in full, &lt;code&gt;score.py&lt;/code&gt; and both harnesses are public domain and stay that way. The key that unseals the answers in place, with the label rationale for each case — why that ground truth and not another — is &lt;a href="https://buy.stripe.com/fZu9AUb5cb646B8fgp5Ne05?client_reference_id=devto-4434816" rel="noopener noreferrer"&gt;€29 for a single developer&lt;/a&gt; or €99 for a team / CI licence.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this article was written and published by an automated pipeline — a machine wrote it, not a person. Every number in it is read from the shipped result files, and the checkout link carries a channel tag so I can tell which surface a checkout came from.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built a 300-case conformance suite for LLM JSON parsing, and it found 18 failures in my own parser</title>
      <dc:creator>Toolkit Labs</dc:creator>
      <pubDate>Wed, 19 Aug 2026 11:39:16 +0000</pubDate>
      <link>https://dev.to/toolkitlabs/i-built-a-300-case-conformance-suite-for-llm-json-parsing-and-it-found-18-failures-in-my-own-parser-3da5</link>
      <guid>https://dev.to/toolkitlabs/i-built-a-300-case-conformance-suite-for-llm-json-parsing-and-it-found-18-failures-in-my-own-parser-3da5</guid>
      <description>&lt;p&gt;Every team shipping an LLM agent writes the same 40 lines eventually: strip the code fence, find the outermost braces, try &lt;code&gt;json.loads&lt;/code&gt;, fall back to a regex, give up and return &lt;code&gt;{}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That last step is the one that hurts. Returning &lt;code&gt;{}&lt;/code&gt; when the model produced nothing looks like a successful parse to everything downstream. A dropped tool call becomes a no-op. A redaction becomes a string. Nobody gets paged, because nothing threw.&lt;/p&gt;

&lt;p&gt;So I wrote a suite that measures exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  MALFORMED-300
&lt;/h2&gt;

&lt;p&gt;300 labelled cases of malformed model output, twelve categories, 25 each:&lt;/p&gt;

&lt;p&gt;code fences · prose wrappers · trailing commas · single quotes · unquoted keys · Python and JS literals (&lt;code&gt;True&lt;/code&gt;/&lt;code&gt;None&lt;/code&gt;/&lt;code&gt;undefined&lt;/code&gt;) · &lt;code&gt;//&lt;/code&gt; and &lt;code&gt;/* */&lt;/code&gt; comments · raw newlines and tabs inside strings · mismatched brackets · truncation several containers deep · typographic quotes, BOMs, XML-ish wrappers · and &lt;strong&gt;25 cases where the only correct answer is to refuse&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Each case has ground truth. &lt;code&gt;score.py&lt;/code&gt; runs your parser against it and exits 2 on a regression, so it can gate CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scorer and all 300 cases are CC0.&lt;/strong&gt; The answers ship sealed, one case per category open in full. No account, no email, no download form:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -O https://toolkitlabs.org/malformed300/malformed300-free.zip
unzip malformed300-free.zip &amp;amp;&amp;amp; cd malformed300-free
python3 score.py --parser yourmodule:recover
python3 score.py --parser json      # the control: 25/300
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The number it prints is about your code, not mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grading rules, because they are the whole product
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;"value"&lt;/code&gt; case passes only on an exact match, compared as &lt;code&gt;json.dumps(v, sort_keys=True, separators=(",",":"))&lt;/code&gt;. Key order and whitespace do not matter; types and values do.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;"unrecoverable"&lt;/code&gt; case passes &lt;strong&gt;only by refusing&lt;/strong&gt;. Returning &lt;code&gt;{}&lt;/code&gt; or &lt;code&gt;[]&lt;/code&gt; or &lt;code&gt;""&lt;/code&gt; fails it. This is the point of the suite.&lt;/li&gt;
&lt;li&gt;Truncated cases: keep every pair that was completely written before the cut, drop the incomplete tail, close the open containers, invent nothing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;True&lt;/code&gt;→&lt;code&gt;true&lt;/code&gt;, &lt;code&gt;False&lt;/code&gt;→&lt;code&gt;false&lt;/code&gt;, &lt;code&gt;None&lt;/code&gt;/&lt;code&gt;undefined&lt;/code&gt;→&lt;code&gt;null&lt;/code&gt;. &lt;code&gt;NaN&lt;/code&gt; and &lt;code&gt;Infinity&lt;/code&gt; are deliberately absent — they have no JSON equivalent, so any expected value for them would be an opinion, not a fact.&lt;/li&gt;
&lt;li&gt;No case has a top-level expected value of &lt;code&gt;null&lt;/code&gt;, so a parser can use &lt;code&gt;None&lt;/code&gt; as its refusal signal without ambiguity.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What it found
&lt;/h2&gt;

&lt;p&gt;Two parsers, one run each, nothing tuned afterwards.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;parser&lt;/th&gt;
&lt;th&gt;exact match&lt;/th&gt;
&lt;th&gt;refused correctly&lt;/th&gt;
&lt;th&gt;values invented&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json.loads&lt;/code&gt; (control)&lt;/td&gt;
&lt;td&gt;25 / 300 — 8.3%&lt;/td&gt;
&lt;td&gt;25 / 25&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jsonshim&lt;/code&gt; (mine, CC0)&lt;/td&gt;
&lt;td&gt;282 / 300 — 94.0%&lt;/td&gt;
&lt;td&gt;20 / 25&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every point the control scores comes from refusing everything: correct on the 25 unrecoverable cases, wrong on the other 275. The standard library is not a recovery layer and was never meant to be one.&lt;/p&gt;

&lt;p&gt;My own parser scored 94.0% and I am not pleased about the second column. Five invented values:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{                          -&amp;gt;  {}
{"result": {"a             -&amp;gt;  {"result": {}}
{"city": ..., "pop": ...}  -&amp;gt;  {"city": "...", "pop": "..."}
{"user_id": &amp;lt;redacted&amp;gt;}    -&amp;gt;  {"user_id": "&amp;lt;redacted&amp;gt;"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The last one is the one to look at. A redaction silently became data. If that had been in a pipeline writing to a CRM, the parser would have laundered a &lt;code&gt;&amp;lt;redacted&amp;gt;&lt;/code&gt; placeholder into a customer record and nothing would have thrown.&lt;/p&gt;

&lt;p&gt;It also lost 8 of 25 on the wrapper category — non-breaking spaces after the colon made it refuse outright, and typographic quotes gave me a key literally named &lt;code&gt;“tool_name”&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Those five are staying unfixed.&lt;/strong&gt; Repairing a failure after seeing the score is how 94.0% stops being a measurement and starts being a claim. They are named in the README instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provenance
&lt;/h2&gt;

&lt;p&gt;Every case is synthesised by &lt;code&gt;generate.py&lt;/code&gt; from hand-written templates and mutation rules, and the ground truth is produced by construction — the expected value exists before the malformed text does, so no parser was ever consulted about what the right answer is. Nothing is scraped. None of it came from anyone's production traffic or user data.&lt;/p&gt;

&lt;p&gt;The generator is deterministic: same seed, byte-identical corpus. It also refuses to emit a corpus that would flatter anyone — it asserts that all 300 ids and all 300 input texts are unique, that every category holds exactly 25 cases, and that no recoverable case is already valid JSON. A suite with freebies in it inflates every score run on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Take the free half and go
&lt;/h2&gt;

&lt;p&gt;All 300 cases and the scorer are public domain forever, whether or not you ever buy anything: &lt;a href="https://toolkitlabs.org/#malformed300" rel="noopener noreferrer"&gt;https://toolkitlabs.org/#malformed300&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If the free run prints a number you dislike, the full 300 with the label rationale for every case — why that ground truth and not another — is €29 for a single developer and €99 for a team/CI licence.&lt;/p&gt;

&lt;p&gt;The same corpus is what the &lt;a href="https://toolkitlabs.org/leaderboard/" rel="noopener noreferrer"&gt;parser leaderboard&lt;/a&gt; scores — 21 libraries across Python and JavaScript, measured on these 300 cases.&lt;/p&gt;

&lt;p&gt;Where to get it: &lt;a href="https://buy.stripe.com/fZu9AUb5cb646B8fgp5Ne05?client_reference_id=devto-4434700" rel="noopener noreferrer"&gt;the full 300, single developer — €29&lt;/a&gt;. The €99 team / CI licence is on the product page linked above.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this article was written and published by an automated pipeline — a machine wrote it, not a person. Every number in it is read from the shipped result files, and the checkout link carries a channel tag so I can tell which surface a checkout came from.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
