Technical Insights

Your DSPy field constraints never reach the model

DSPy drops Pydantic field metadata before your schema leaves the process, so no structured-output backend ever sees those constraints. Here is what to do instead.

Posted by
Mohsen Arjmandi
Posted date
August 11, 2026

If you write this DSPy signature:

import dspy, pydantic
from typing import Annotated

class Score(dspy.Signature):
    text: str = dspy.InputField()
    score: float = dspy.OutputField(ge=0.0, le=1.0, multiple_of=0.25)

you probably believe the model is constrained to emit a score between 0 and 1 in steps of 0.25. It is not. Print the schema DSPy actually derives for structured outputs:

from dspy.adapters.json_adapter import _get_structured_outputs_response_format
print(_get_structured_outputs_response_format(Score, True).model_json_schema())
# {'properties': {'score': {'title': 'Score', 'type': 'number'}}, ...}

ge, le, and multiple_of are gone. DSPy's signature-to-model derivation drops pydantic Field metadata before the schema leaves your process, so no structured-output backend ever sees those constraints, not OpenAI's, not vLLM's, not any grammar engine. Your pipeline type-checks, your outputs parse, and a score of 0.37 sails through until something downstream notices. We found this while wiring GRID into DSPy and verified it against dspy 3.2.1. It affects every backend equally.

Two practical consequences:

  1. Constraints must live in the annotation, not in Field kwargs. tags: set[str] survives derivation and becomes uniqueItems. Literal["a", "b"] survives. Nested pydantic models survive. OutputField(multiple_of=...) does not.
  2. You want to learn this at build time, not in production. That is what we built.

The adapter: unenforceable signatures fail at program build

GRID is our constrained-decoding engine (Apache-2.0). Its contract is that nothing fails silently: every constraint in a schema is either enforced by the token mask, recorded by name so you know exactly what to re-validate, or declared unsupported up front. That contract turns out to be the missing piece for typed pipelines:

pip install grid-guardrail dspy

from grid.integrations.dspy_adapter import GridJSONAdapter, assert_enforceable

adapter = GridJSONAdapter(strict=True)
dspy.configure(adapter=adapter)

class Extract(dspy.Signature):
    text: str = dspy.InputField()
    verdict: str = dspy.OutputField()
    tags: set[str] = dspy.OutputField()   # set -> uniqueItems

program = dspy.Predict(Extract)
assert_enforceable(program, adapter)
# SignatureNotEnforceable: strict: uniqueItems at $.tags

That exception fires when you build the program, not three weeks later when a duplicate tag corrupts a join. Drop strict=True and the same information arrives as data instead of an error:

adapter = GridJSONAdapter()
adapter.recorded_paths_for(Extract)   # {'$.tags': {'uniqueItems'}}

recorded_paths_for is the honesty contract as an API. It returns the exact, named constraints that GRID accepted but did not mask-enforce, located on the output field they live on, so your validation code checks $.tags for uniqueness and nothing else. (recorded_for returns the same information as a flat set of names.) For most pydantic-derived signatures the set is empty, since enum and Literal fields, nested models, and required keys all sit in the easy region of JSON Schema. At that point the parse-retry machinery in your framework becomes dead weight, because typed fields cannot arrive malformed.

The same check runs repo-wide as a CI gate:

$ python -m grid.integrations.dspy_check src/pipelines.py --strict
ENFORCEABLE  Summarize
RECORDED     Extract  [$.tags: uniqueItems]

2 signature(s): 1 enforceable, 1 with recorded residue, 0 declared unsupported
$ echo $?   # --strict: nonzero unless everything is mask-enforceable
1

A teammate's set[str] fails the PR, not the pipeline three weeks later.

Running against a GRID-enabled server (our vLLM integration), one argument moves enforcement server-side:

GridJSONAdapter(mode="server")   # attaches the compiled grammar per request

Client mode, the default, changes nothing about your requests and works against any OpenAI-compatible endpoint today.

When you should not use this

The walk-away cases deserve the same clarity. If your signatures are a handful of enum-and-string shapes you already test end to end, the provider's native structured outputs are enough, and this adapter adds a dependency for an empty residue set. The adapter earns its place when signatures come from many hands, evolve weekly, and feed systems where "parsed" and "correct" are different words.

Everything above is measured and committed. The engine's full JSONSchemaBench results (11,306 real-world schemas, three engines, one machine, with per-schema statuses in the repo) are at github.com/evolutionIdGmbH/grid, including the rows where we lose.

About us
For more than two decades, evolutionID has been helping organizations bring clarity and control to their identity and access processes. We focus on what matters most: secure, reliable workflows that are easy to use and built to last.

We combine Physical Identity & Access Management (PIAM), card and employee management, and RFID-supported workflows into a cohesive, integrated solution. Our modular building blocks allow you to evolve your identity and access systems step by step without disrupting your existing operations. The result: less complexity, greater transparency, and enhanced security in your day-to-day business.

As a long-term partner, we support our clients every step of the way, from analysis and architecture to implementation, migration, and ongoing support. With teams in Munich, Bonn, and Frankfurt, we work closely with organizations across the DACH region to create access structures that are secure, stable, and ready for whatever the future holds.