Repository navigation
Show and Tell: A package with StructuredOutputAdapter for getting better structured output from smaller models
#10439
rohitgarudls
started this conversation in
Show and tell
Replies: 2 comments
|
Added JevLM and JevAdapter support with OpenRouter provider as default |
0 replies
|
Added SemIfLM for SemIf-OpenJev support. This is the best open-source alternative to Typesafe AI's Jev |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Showcasing a compact-schema DSPy adapter for small local models. I have benchmarked it across 15 model×corpus. DSPy silently downgrades local models out of constrained decoding, and a grammar does not guarantee parseable output. Both are below, with confidence intervals and the raw replies committed.
Repo: https://github.com/rohitgarud/llm-schema-lite ·
pip install "llm-schema-lite[dspy]"· MITThe one-liner
Signatures don't change. What changes is the schema DSPy puts in the prompt, and how the reply is parsed back. Constraints ride inline in the type line instead of a
$defsblock:231 tokens of JSON Schema → 91. Typically 40-70% fewer schema tokens; check yours with
compare_tokens().And you keep your own Pydantic models
The ergonomic half, which I think matters more day-to-day than the token count: your existing models go in as-is, inputs as well as outputs. No base class, no mixin, no decorator, no registration step. The adapter reads the raw annotation, so the
Companymodel already serving your API layer and your DB code drops straight into a signature instead of being flattened into multipleInputFields:Nested models and
list[Model]are followed, and the field metadata survives into the prompt:description,ge/le,min_length/max_length,pattern:So Pydantic stays the single source of truth for constraints and descriptions, rather than having them restated in a signature docstring where they drift from the model. (Compact input schemas are a JSONISH/YAML feature;
OutputMode.JSONsends the verbose schema.)The comparison I should have run first
The obvious objection to any prompt-and-parse library: why not constrain the sampler?
Outlines, XGrammar, vLLM guided decoding, llama.cpp GBNF and Ollama, which honours a
json_schemaresponse_formateven though LiteLLM reports the capability unsupported for every Ollama prefix. Because of that, DSPy's stockJSONAdaptersilently downgrades to{"type": "json_object"}: valid JSON of any shape, not your schema. If you thought you were getting structured outputs from a local model through DSPy, you may not be.So I added a
json-constrainedarm that forces the real thing, and pointed it at my own adapters. Three models × five corpora × 30 cases, paired bootstrap, 5000 resamples, 95% CI on the recall difference.Verdict per cell, mine vs constrained:
falcon3:1bllama3.2:1bqwen3.5:0.8b0 wins, 7 ties, 8 losses. Worst single cell: patient-notes on qwen, −0.487
[−0.618, −0.345]. The constrained arm was alsookon 30/30 cases in 13 of 15 cells, and I took that to mean it cannot fail to produce parseable output. It can, and a later section is about exactly how.What DSPy actually does about grammars
DSPy supports OpenAI's
response_formatthrough theJSONAdapter.ChatAdapternever sets it and theBAMLAdaptersubclassesJSONAdapterand inherits the same path. That path picks one of three tiers, per call (json_adapter.py:57-66):response_formatnot inlm.supported_params{"type": "json_object"}not lm.supports_response_schemaTwo things here surprised me.
The capability table is model-keyed, and wrong for local models:
That last row is the one to notice, which is not an Ollama quirk. Point an
openai/prefix at any OpenAI-compatible local server and LiteLLM still answers False, because it doesn't recognise the model name, so the downgrade hits vLLM and llama.cpp servers too. Ollama honours a realjson_schemaresponse_formatregardless of what the table claims and we silently get any-shape JSON.requireddoes not mean non-null.enforce_requiredsetsrequired = [every property]andadditionalProperties: falserecursively, which reads like "the model must fill everything". It isn't: optional fields still serialise asanyOf: [X, null], so the key is mandatory whilenullremains a legal value. The schema never compelled the fabrication described below.nullwas available on nearly every field and the models filled them anyway.One thing to note is that DSPy is not performing constrained decoding, it is requesting it. Whether a grammar actually constrains sampling is entirely handled by the backend. DSPy does not provide grammer api, which can include
guided_json,guided_regex, GBNF, Outlines or XGrammar integration.What the package does about it: today, exactly the same thing
llm-schema-lite ships now constrained decoding as one flag, default off:
The flag suppresses only the capability clause. An open-ended mapping or a
ToolCallsoutput still falls back tojson_object, YAML still sends nothing, a tool-carrying signature still backs off, and an LM that doesn't advertiseresponse_formatat all is left alone. It overrides a stale capability table, never an endpoint that tells you it cannot accept the parameter.The adapter alone fails 37% of the time on small models, while adding the grammar takes the failures down to 2.4%. The compact prompt does not beat grammar, but it composes with one at a large discount.
But note that a grammar guarantees shape, not truth. It removes the model's ability to fail, and a model that can't fail confabulates instead. On the other hand, compact prompts do not make a model more truthful. Not having a grammar does, by leaving it the option to fail. I checked whether this was a schema artifact, but it isn't. Nearly every field permits
nullin the generated schema (patient-notes 12/13, pii 56/57). The model could emitnulland declined. On one synthetic case, it invented an entire employer and clampedyearsto 50, the schema's exact maximum.A bug worth mentioning
While benchmarking, I found the adapter reporting success on replies that were just the own rendered schema handed back:
{"name": "string", "age": "int"}. Against a signature whose fields are allstr, that parses and validates perfectly. The failure mode isn't an exception; it's a silent 100% invention rate that scores as a win.0.7.0 detects and rejects it. Re-scoring the full sweep moved exactly 3 cells, all reclassifying echoes that had counted as successes. Worth checking your own eval isn't counting these.
A grammar does not guarantee parseable output
The sharpest thing I found, and it isn't about the package at all.
19 cases across the constrained arms silently fell back to
json_objectmid-run.llama3.2:1b/ insurance-claimsValidationErrorllama3.2:1b/ syntheticValidationErrorllama3.2:1b/ patient-notesjson-constrainedValidationErrorllama3.2:1b/ patient-notesValidationError13 of 13 were a Pydantic
ValidationErroron the model's reply; none was a schema-build failure. Thedspyrow is the one that generalises, which is the stockJSONAdapterbehaviour, not the package subclass.The failing replies are truncated mid-structure, and several are looping a repeated unit:
' Cedar Lane,'×33, or a duplicated array element like'"make_model": "Honda CR-V", "year": …'. A grammar permits every bit of this. An arbitrarily long string is legal JSON; so is an arbitrarily long array. The sampler stays perfectly schema-compliant right up until the token budget severs it.Constrained decoding constrains structure, not termination. If you lean on a grammar for reliability, you still need a parse-failure path.
Caveats
json_objecton 19 cases total. Those rows readmixedand the case ids are in the repo. The cause is not a failed schema build.piiandpatient-notessit below their all-null floor (0.949 / 0.356) for most arms; extracting nothing often scores better than extracting.Every raw reply is committed, so any number above re-scores with no model and no network:
make bench-dspy BENCH_ARGS="--accuracy --corpus pii --replay <csv>"How much of JSON Schema does it actually handle?
Fair question for anything claiming to compact schemas, so I ran the whole of JSONSchemaBench through it: 10 configs, 9,542 real-world schemas, Kubernetes and the JSON Schema Store included.
Two things I would rather say myself than have someone find:
Rendering ≠ validation. Every keyword is validated;
32/39is what survives into the prompt string. Those gaps are also the corpus's rarest keyword:minProperties1.6%,maxProperties0.6%,if/then0.5%,else0.2%,unevaluatedProperties0.02%, anddependentRequiredappears nowhere in all 9,542.Quote the median, not the mean.
Github_hardmedians 40.1% against a mean of 13.9%, because a few pathological schemas drag the average far below the typical one.oneOf,patternProperties,notandcontainsare ❌ for every constrained-decoding engine in that benchmark: LLGuidance,llama.cpp, Outlines, XGrammar, OpenAI, Gemini and ✅ here. Their gaps are architectural: an FSM cannot express
notor unbounded recursion. For this package, they are implementation debt. That asymmetry is the one durable argument for prompt-and-parse I found in this whole exercise, and it is worth noting it cuts the opposite way from every benchmark above.The failure mode
A
$refexpands inline at its use site, so a cyclic definition graph can render larger than the schema it came from. Back-references (object // defined above: Address) keep that rare: 56 of 9,542 (0.59%) still expand, and past the worst case the next is 8.67×. The worst,o13029, renders at 82×, with seven mutually recursive definitions, the accepted limit of repeat-suppression.The part worth reporting: the corpus's worst schema used to render at 45× its input and time out. That was not an architectural limit; it was one over-broad cache-invalidation rule and a truncation anywhere poisoned every ancestor frame, so back-references almost never fired (103 of 51,808 expansions cached). Scoping it took that schema to 0.35× and removed every timeout in the corpus.
So when would you use this?
Honestly: if you have a grammar, use the grammar and put this underneath it. The composed model performs better against the verbose-schema grammar at ~30-45% fewer tokens, and this adapter's own failure rate falls from 37% to 2.4%.
If you can't constrain the sampler, which is the case for plenty of endpoints, this is prompt-and-parse, and it is much weaker: 0 wins, 7 ties, 8 losses, with a 37% failure rate on sub-1.2B models. The invention advantage exists only in that weaker mode, and a fabricated value has to cost you more than a missing one for it to be worth the trade.
The benchmarks were taken from https://github.com/thedataquarry/structured-outputs/ by @prrao87, which benchmarked the BAMLAdapter.
Your honest feedback, even if it's criticism, is much appreciated. Will be running extensive benchmarks on small models to check if the compaction also helps with the weight fine-tuning of the small models with DSPy's BootstrapFinetune
All reactions