From 8fc092cf20c1f00d2ca6953ade1fdf0761baa737 Mon Sep 17 00:00:00 2001 From: darin Date: Sun, 26 Jul 2026 14:37:40 -0700 Subject: [PATCH 1/7] style: cargo fmt (fixes fmt --check on main) Co-Authored-By: Claude Fable 5 --- crates/dspy-rs/tests/test_predict_lm_override.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/dspy-rs/tests/test_predict_lm_override.rs b/crates/dspy-rs/tests/test_predict_lm_override.rs index 4d2ab61..499fb89 100644 --- a/crates/dspy-rs/tests/test_predict_lm_override.rs +++ b/crates/dspy-rs/tests/test_predict_lm_override.rs @@ -61,9 +61,7 @@ async fn predict_uses_per_instance_lm_over_global() { let (override_lm, _override_client) = make_test_lm(vec![override_response]).await; // Predict with per-instance LM override - let predict = Predict::::builder() - .lm(override_lm) - .build(); + let predict = Predict::::builder().lm(override_lm).build(); let result = predict .call(QAInput { From f7afeb5b32629a87c0f4f996a962db305c46d59b Mon Sep 17 00:00:00 2001 From: darin Date: Sun, 26 Jul 2026 14:37:40 -0700 Subject: [PATCH 2/7] feat(jsonish): accept Python literals True/False/None in fixing parser LLMs routinely emit Pythonish JSON ({'age': None, 'ok': True}). Map the Python spellings onto null/true/false so such completions parse instead of degrading to strings. Ported from the dsrs_dspy adapter's vendored jsonish, where this ran in production. Co-Authored-By: Claude Fable 5 --- .../jsonish/src/jsonish/parser/fixing_parser.rs | 15 +++++++++++++++ .../parser/fixing_parser/json_collection.rs | 6 +++--- .../parser/fixing_parser/json_parse_state.rs | 2 +- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser.rs b/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser.rs index f62a8fb..d744780 100644 --- a/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser.rs +++ b/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser.rs @@ -147,6 +147,21 @@ mod tests { } } + #[test] + fn test_python_literal_null_and_boole() { + let opts = ParseOptions::default(); + let vals = parse(r#"{"missing": None, "yes": True, "no": False}"#, &opts).unwrap(); + + match &vals[0].0 { + Value::Object(fields, _) => { + assert!(matches!(&fields[0], (key, Value::Null) if key == "missing")); + assert!(matches!(&fields[1], (key, Value::Boolean(true)) if key == "yes")); + assert!(matches!(&fields[2], (key, Value::Boolean(false)) if key == "no")); + } + _ => panic!("Expected object"), + } + } + #[test] fn test_partial_object_newlines() { let opts = ParseOptions::default(); diff --git a/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser/json_collection.rs b/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser/json_collection.rs index e4519ba..887ab31 100644 --- a/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser/json_collection.rs +++ b/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser/json_collection.rs @@ -102,11 +102,11 @@ impl From for Option { } JsonCollection::UnquotedString(s, completion_state) => { let s = s.trim(); - if s == "true" { + if matches!(s, "true" | "True") { Value::Boolean(true) - } else if s == "false" { + } else if matches!(s, "false" | "False") { Value::Boolean(false) - } else if s == "null" { + } else if matches!(s, "null" | "None") { Value::Null } else if let Ok(n) = s.parse::() { Value::Number(n.into(), completion_state) diff --git a/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser/json_parse_state.rs b/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser/json_parse_state.rs index 3fb632b..2f079b9 100644 --- a/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser/json_parse_state.rs +++ b/vendor/baml/crates/jsonish/src/jsonish/parser/fixing_parser/json_parse_state.rs @@ -173,7 +173,7 @@ impl JsonParseState { // Check if the token is a valid json character match v.as_str() { - "true" | "false" | "null" => true, + "true" | "false" | "null" | "True" | "False" | "None" => true, _ => { // Check if the token parses as a number if v.parse::().is_ok() { From 6064988eb76226112fba56d97c7e45e029862bac Mon Sep 17 00:00:00 2001 From: darin Date: Sun, 26 Jul 2026 14:37:40 -0700 Subject: [PATCH 3/7] feat(bamltype): export SchemaRegistry dspy-py compiles JSON Schema (from DSPy/pydantic signatures) into BAML classes/enums directly, without facet shapes, so it needs the registry that schema_builder uses internally. Co-Authored-By: Claude Fable 5 --- crates/bamltype/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/bamltype/src/lib.rs b/crates/bamltype/src/lib.rs index 62c3c81..21d9c7b 100644 --- a/crates/bamltype/src/lib.rs +++ b/crates/bamltype/src/lib.rs @@ -63,6 +63,7 @@ pub use runtime::{ pub mod adapters; mod schema_registry; +pub use schema_registry::SchemaRegistry; pub mod facet_ext; From af825a792aa536f95cedc6a62351762f76584d75 Mon Sep 17 00:00:00 2001 From: darin Date: Sun, 26 Jul 2026 14:37:41 -0700 Subject: [PATCH 4/7] feat(dspy-py): DSPy adapter as an installable Python package (dsrs-dspy) A PyO3 extension exposing DSRs BAML-style schema rendering and JSONish parsing to Python, plus a pure-Python DSRSBAMLAdapter that drops into DSPy as a ChatAdapter replacement. Install (no PyPI needed; builds from source via maturin): uv add "dsrs-dspy @ git+https://github.com/krypticmouse/DSRs@main#subdirectory=crates/dspy-py" - abi3-py39 wheel: one build covers CPython >= 3.9 - Rust tests: unit + property (schema fuzzing, JSONish noise, a matrix driven by real pydantic.TypeAdapter(...).json_schema() output) - Python tests: render/parse end-to-end against a real DSPy install Co-Authored-By: Claude Fable 5 --- Cargo.lock | 89 +- crates/dspy-py/Cargo.toml | 20 + crates/dspy-py/README.md | 93 ++ crates/dspy-py/pyproject.toml | 23 + crates/dspy-py/python/dsrs_dspy/__init__.py | 3 + crates/dspy-py/python/dsrs_dspy/adapter.py | 223 ++++ crates/dspy-py/src/lib.rs | 1091 +++++++++++++++++ crates/dspy-py/src/property_tests.rs | 624 ++++++++++ .../test_adapter.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 16479 bytes crates/dspy-py/tests/python/test_adapter.py | 97 ++ 10 files changed, 2260 insertions(+), 3 deletions(-) create mode 100644 crates/dspy-py/Cargo.toml create mode 100644 crates/dspy-py/README.md create mode 100644 crates/dspy-py/pyproject.toml create mode 100644 crates/dspy-py/python/dsrs_dspy/__init__.py create mode 100644 crates/dspy-py/python/dsrs_dspy/adapter.py create mode 100644 crates/dspy-py/src/lib.rs create mode 100644 crates/dspy-py/src/property_tests.rs create mode 100644 crates/dspy-py/tests/python/__pycache__/test_adapter.cpython-314-pytest-9.1.1.pyc create mode 100644 crates/dspy-py/tests/python/test_adapter.py diff --git a/Cargo.lock b/Cargo.lock index 11e8b33..36517ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1207,6 +1207,18 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dspy-py" +version = "0.1.2" +dependencies = [ + "bamltype", + "proptest", + "pyo3", + "regex", + "serde", + "serde_json", +] + [[package]] name = "dspy-rs" version = "0.7.3" @@ -3252,6 +3264,64 @@ dependencies = [ "unarray", ] +[[package]] +name = "pyo3" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +dependencies = [ + "libc", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", +] + +[[package]] +name = "pyo3-build-config" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.28.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.106", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3973,15 +4043,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -4234,6 +4305,12 @@ dependencies = [ "libc", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "target-triple" version = "0.1.4" @@ -5575,6 +5652,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zstd" version = "0.13.3" diff --git a/crates/dspy-py/Cargo.toml b/crates/dspy-py/Cargo.toml new file mode 100644 index 0000000..4e0aaa4 --- /dev/null +++ b/crates/dspy-py/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dspy-py" +version = "0.1.2" +edition = "2024" +description = "PyO3 bridge to expose BAML rendering and JSONish parsing to DSPy" +license = "Apache-2.0" + +[lib] +name = "_dsrs_dspy" +crate-type = ["cdylib", "rlib"] + +[dependencies] +bamltype = { path = "../bamltype" } +pyo3 = { version = "0.28.0", features = ["macros", "abi3-py39"] } +regex = "1.11.2" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = { version = "1.0.145", features = ["preserve_order"] } + +[dev-dependencies] +proptest = "1.7.0" diff --git a/crates/dspy-py/README.md b/crates/dspy-py/README.md new file mode 100644 index 0000000..7c69b96 --- /dev/null +++ b/crates/dspy-py/README.md @@ -0,0 +1,93 @@ +# dsrs-dspy (dspy-py) + +A drop-in [DSPy](https://github.com/stanfordnlp/dspy) adapter backed by DSRs: +BAML-style schema rendering and JSONish response parsing, exposed to Python +through a PyO3 extension. + +Compared to DSPy's stock `ChatAdapter`, this adapter: + +- renders output-field schemas in BAML's compact, LLM-friendly format + (including nested pydantic models, enums, unions, `$defs`/`$ref`), and +- parses completions with JSONish, which tolerates the sins real models + commit: trailing commas, Python literals (`True`/`False`/`None`), + single quotes, and whitespace-mangled `[[ ## field ## ]]` markers. + +There is no silent fallback to a second model call: parse failures surface as +`AdapterParseError`. + +## Install + +Not published to PyPI. Install from git (requires a Rust toolchain; the wheel +builds from source via maturin): + +```bash +uv add "dsrs-dspy @ git+https://github.com/krypticmouse/DSRs@main#subdirectory=crates/dspy-py" +# or +pip install "dsrs-dspy @ git+https://github.com/krypticmouse/DSRs@main#subdirectory=crates/dspy-py" +``` + +## Use + +```python +import dspy +from dsrs_dspy import DSRSBAMLAdapter + +dspy.configure(lm=dspy.LM("openai/gpt-5.2"), adapter=DSRSBAMLAdapter()) +``` + +Everything else is ordinary DSPy — signatures, modules, optimizers. + +## Python API surface + +The compiled module is `dsrs_dspy._dsrs_dspy`: + +- `render_field_structure(spec_json: str) -> str` + — compiles DSPy/pydantic field schemas into BAML `TypeIR` and returns the + BAML-style field structure text. +- `parse_response(spec_json: str, completion: str, is_done: bool = True) -> str` + — parses `[[ ## field ## ]]` sections with JSONish and returns a JSON string + of parsed output fields. + +`DSRSBAMLAdapter` (pure Python, in `dsrs_dspy/adapter.py`) subclasses DSPy's +`ChatAdapter` and overrides `format_field_structure()` and `parse()` with the +functions above, keeping DSPy's own field coercion via +`dspy.adapters.utils.parse_value`. + +### Spec shape (`spec_json`) + +```json +{ + "input_fields": [ + {"name": "question", "description": "", "format": null, "schema": {"type": "string"}} + ], + "output_fields": [ + {"name": "answer", "description": "", "format": null, "schema": {"type": "string"}} + ], + "instruction": "Given ..." +} +``` + +`schema` is pydantic/JSON-Schema-like and may include `$defs` + local `$ref`. + +Supported schema features: primitives, objects (`properties`/`required`), +maps (`additionalProperties`), arrays (`items`/`prefixItems`), unions +(`anyOf`/`oneOf`/`allOf`/`type: [..]`), enums, `const`, and local +`#/$defs/...` / `#/definitions/...` references. + +## Tests + +Rust unit + property tests (schema fuzzing, JSONish noise, description +propagation, and a matrix driven by real `pydantic.TypeAdapter(...).json_schema()` +output — that last one needs a Python with pydantic importable and is skipped +otherwise): + +```bash +cargo test -p dspy-py +``` + +Python-side adapter tests against a real DSPy install: + +```bash +uv venv .venv && VIRTUAL_ENV=$PWD/.venv uv pip install . dspy pytest +.venv/bin/pytest tests/python/ +``` diff --git a/crates/dspy-py/pyproject.toml b/crates/dspy-py/pyproject.toml new file mode 100644 index 0000000..97ea063 --- /dev/null +++ b/crates/dspy-py/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "dsrs-dspy" +version = "0.1.2" +description = "DSRS-backed DSPy adapter using BAML rendering and JSONish parsing" +requires-python = ">=3.9" +dependencies = ["dspy>=3.0", "pydantic>=2"] + +[project.urls] +Repository = "https://github.com/krypticmouse/DSRs" + +[tool.maturin] +# Nest the compiled extension inside the python package so ONE wheel ships both +# `dsrs_dspy` (pure python) and its `_dsrs_dspy` native module — no PYTHONPATH, +# no separate install step. +module-name = "dsrs_dspy._dsrs_dspy" +python-source = "python" +# extension-module: don't hard-link libpython, so the abi3 wheel loads under any +# CPython >= 3.9 (without this the .so segfaults across python versions). +features = ["pyo3/extension-module"] diff --git a/crates/dspy-py/python/dsrs_dspy/__init__.py b/crates/dspy-py/python/dsrs_dspy/__init__.py new file mode 100644 index 0000000..b8e1d2b --- /dev/null +++ b/crates/dspy-py/python/dsrs_dspy/__init__.py @@ -0,0 +1,3 @@ +from .adapter import DSRSBAMLAdapter + +__all__ = ["DSRSBAMLAdapter"] diff --git a/crates/dspy-py/python/dsrs_dspy/adapter.py b/crates/dspy-py/python/dsrs_dspy/adapter.py new file mode 100644 index 0000000..5d0fcd0 --- /dev/null +++ b/crates/dspy-py/python/dsrs_dspy/adapter.py @@ -0,0 +1,223 @@ +"""DSPy adapter backed by DSRS BAML rendering + JSONish parsing via PyO3.""" + +from __future__ import annotations + +import json +import types +from functools import lru_cache +from typing import Any, Union, get_args, get_origin + +from pydantic import BaseModel, TypeAdapter + +from dspy.adapters.chat_adapter import ChatAdapter +from dspy.adapters.utils import format_field_value as original_format_field_value +from dspy.adapters.utils import parse_value +from dspy.signatures.signature import Signature +from dspy.utils.exceptions import AdapterParseError + +_IMPORT_ERROR: Exception | None = None +try: + from ._dsrs_dspy import parse_response as _rust_parse_response + from ._dsrs_dspy import render_field_structure as _rust_render_field_structure +except Exception as exc: # pragma: no cover - import availability is environment-specific. + _IMPORT_ERROR = exc + _rust_parse_response = None + _rust_render_field_structure = None + + +def _field_description(field_info: Any) -> str: + extra = field_info.json_schema_extra or {} + desc = extra.get("desc") + if isinstance(desc, str): + # DSPy auto-infers placeholder desc like "${field_name}". + if desc.startswith("${") and desc.endswith("}"): + return "" + return desc + return field_info.description or "" + + +def _field_spec(name: str, field_info: Any) -> dict[str, Any]: + schema = TypeAdapter(field_info.annotation).json_schema() + return { + "name": name, + "description": _field_description(field_info), + "format": (field_info.json_schema_extra or {}).get("format"), + "schema": schema, + } + + +def _allows_none(annotation: Any) -> bool: + return annotation is type(None) or type(None) in get_args(annotation) + + +def _restore_pydantic_defaults(value: Any, annotation: Any) -> Any: + """Map BAML's null-for-defaultable-field representation to Pydantic defaults. + + BAML renders a non-required model property as ``T or null``. Pydantic may + instead define it as a non-nullable ``T`` with a default/default_factory. + A null at that boundary means "use the declared default", not a literal + value to validate against ``T``. + """ + if value is None: + return value + + origin = get_origin(annotation) + args = get_args(annotation) + if origin is list and isinstance(value, list) and args: + return [_restore_pydantic_defaults(item, args[0]) for item in value] + if origin is dict and isinstance(value, dict) and len(args) == 2: + return { + key: _restore_pydantic_defaults(item, args[1]) for key, item in value.items() + } + if origin in (types.UnionType, Union): + non_null = [choice for choice in args if choice is not type(None)] + if len(non_null) == 1: + return _restore_pydantic_defaults(value, non_null[0]) + + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + if not isinstance(value, dict): + return value + restored = dict(value) + for field_name, field in annotation.model_fields.items(): + if field_name not in restored: + continue + field_value = restored[field_name] + if field_value is None and not field.is_required() and not _allows_none( + field.annotation + ): + restored[field_name] = field.get_default(call_default_factory=True) + else: + restored[field_name] = _restore_pydantic_defaults( + field_value, field.annotation + ) + return restored + return value + + +@lru_cache(maxsize=256) +def _signature_spec_json(signature: type[Signature]) -> str: + spec = { + "input_fields": [_field_spec(name, info) for name, info in signature.input_fields.items()], + "output_fields": [_field_spec(name, info) for name, info in signature.output_fields.items()], + "instruction": signature.instructions, + } + return json.dumps(spec, ensure_ascii=False, separators=(",", ":")) + + +class DSRSBAMLAdapter(ChatAdapter): + """ + DSPy adapter that swaps in DSRS BAML-style rendering and JSONish parsing. + + This adapter is a drop-in `ChatAdapter` replacement for DSPy. It keeps DSPy's + runtime/callback behavior, but disables ChatAdapter's silent second-model-call + fallback and delegates output-format rendering and + response parsing to the Rust extension module (`_dsrs_dspy`). + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + kwargs.setdefault("use_json_adapter_fallback", False) + super().__init__(*args, **kwargs) + + def _require_rust_extension(self) -> None: + if _rust_parse_response is None or _rust_render_field_structure is None: + detail = repr(_IMPORT_ERROR) if _IMPORT_ERROR else "unknown import error" + raise RuntimeError( + "_dsrs_dspy extension is unavailable. Build/install the PyO3 module first. " + f"Import error: {detail}" + ) + + def format_field_structure(self, signature: type[Signature]) -> str: + self._require_rust_extension() + spec_json = _signature_spec_json(signature) + + try: + return _rust_render_field_structure(spec_json) + except Exception as exc: # pragma: no cover - depends on LM/schema shape. + raise AdapterParseError( + adapter_name="DSRSBAMLAdapter", + signature=signature, + lm_response="", + message=f"Failed to render BAML field structure: {exc}", + ) from exc + + def format_user_message_content( + self, + signature: type[Signature], + inputs: dict[str, Any], + prefix: str = "", + suffix: str = "", + main_request: bool = False, + ) -> str: + messages = [prefix] + for key, field_info in signature.input_fields.items(): + if key not in inputs: + continue + + value = inputs.get(key) + if isinstance(value, BaseModel): + formatted_value = value.model_dump_json(indent=2, by_alias=True) + else: + formatted_value = original_format_field_value(field_info=field_info, value=value) + + messages.append(f"[[ ## {key} ## ]]\n{formatted_value}") + + if main_request: + output_requirements = self.user_message_output_requirements(signature) + if output_requirements is not None: + messages.append(output_requirements) + + messages.append(suffix) + return "\n\n".join(message for message in messages if message).strip() + + def parse(self, signature: type[Signature], completion: str) -> dict[str, Any]: + self._require_rust_extension() + spec_json = _signature_spec_json(signature) + + try: + parsed_json = _rust_parse_response(spec_json, completion, True) + parsed_fields = json.loads(parsed_json) + except Exception as exc: + raise AdapterParseError( + adapter_name="DSRSBAMLAdapter", + signature=signature, + lm_response=completion, + message=f"Rust JSONish parse failed: {exc}", + ) from exc + + coerced: dict[str, Any] = {} + for field_name, field in signature.output_fields.items(): + if field_name not in parsed_fields: + raise AdapterParseError( + adapter_name="DSRSBAMLAdapter", + signature=signature, + lm_response=completion, + parsed_result=parsed_fields, + message=f"Missing parsed output field `{field_name}`", + ) + + try: + value = _restore_pydantic_defaults( + parsed_fields[field_name], field.annotation + ) + coerced[field_name] = parse_value(value, field.annotation) + except Exception as exc: + raise AdapterParseError( + adapter_name="DSRSBAMLAdapter", + signature=signature, + lm_response=completion, + parsed_result=parsed_fields, + message=( + f"Failed to coerce parsed output field `{field_name}` " + f"to annotation `{field.annotation}`: {exc}" + ), + ) from exc + + if coerced.keys() != signature.output_fields.keys(): + raise AdapterParseError( + adapter_name="DSRSBAMLAdapter", + signature=signature, + lm_response=completion, + parsed_result=coerced, + ) + + return coerced diff --git a/crates/dspy-py/src/lib.rs b/crates/dspy-py/src/lib.rs new file mode 100644 index 0000000..0635833 --- /dev/null +++ b/crates/dspy-py/src/lib.rs @@ -0,0 +1,1091 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::LazyLock; + +use bamltype::baml_types::ir_type::UnionConstructor; +use bamltype::baml_types::{BamlValue, StreamingMode, TypeIR}; +use bamltype::internal_baml_jinja::types::{Class, Enum, Name, OutputFormatContent}; +use bamltype::{RenderOptions, SchemaRegistry, default_streaming_behavior}; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyModule; +use regex::Regex; +use serde::Deserialize; +use serde_json::{Map, Value}; + +// Whitespace-tolerant: models routinely emit `[[ ## name ##]]` (missing a space) — observed at +// ~6% of deepseek-v4-flash calls in production, where a strict match made the whole completion +// unparseable ("missing output field") and forced an expensive adapter-fallback re-call. The +// canonical render (below) always emits `[[ ## name ## ]]`; only PARSING is lenient. +static FIELD_HEADER_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^\[\[\s*##\s*(\w+)\s*##\s*\]\]").expect("valid marker regex")); + +#[derive(Debug, Clone, Deserialize)] +struct AdapterField { + name: String, + #[serde(default)] + description: String, + #[allow(dead_code)] + #[serde(default)] + format: Option, + schema: Value, +} + +#[derive(Debug, Deserialize)] +struct AdapterSpec { + #[serde(default)] + input_fields: Vec, + #[serde(default)] + output_fields: Vec, + #[serde(default)] + instruction: String, +} + +#[derive(Debug, Clone)] +struct CompiledOutputField { + name: String, + description: String, + type_ir: TypeIR, +} + +#[derive(Debug)] +struct CompiledSpec { + input_fields: Vec, + output_fields: Vec, + output_format: OutputFormatContent, +} + +#[derive(Debug, Default)] +struct SchemaCompiler { + registry: SchemaRegistry, + defs_by_scope: HashMap, + def_aliases: HashMap, + compiled_defs: HashMap, + in_progress_defs: HashSet, + used_names: HashSet, +} + +impl SchemaCompiler { + fn scope_id(name: &str) -> String { + sanitize_identifier(name) + } + + fn add_scope_defs(&mut self, scope: &str, schema: &Value) { + for defs_key in ["$defs", "definitions"] { + let Some(defs_map) = schema.get(defs_key).and_then(Value::as_object) else { + continue; + }; + for (def_name, def_schema) in defs_map { + let scoped_name = format!("{scope}::{def_name}"); + self.defs_by_scope.insert(scoped_name, def_schema.clone()); + } + } + } + + fn unique_name(&mut self, raw: &str) -> String { + let base = sanitize_identifier(raw); + if self.used_names.insert(base.clone()) { + return base; + } + + let mut idx = 2usize; + loop { + let candidate = format!("{base}_{idx}"); + if self.used_names.insert(candidate.clone()) { + return candidate; + } + idx += 1; + } + } + + fn compile_schema( + &mut self, + schema: &Value, + hint_name: Option<&str>, + scope: &str, + ) -> Result { + if let Some(reference) = schema.get("$ref").and_then(Value::as_str) { + return self.compile_ref(reference, scope); + } + + if let Some(enum_values) = schema.get("enum").and_then(Value::as_array) { + return self.compile_enum(schema, enum_values, hint_name); + } + + if let Some(constant) = schema.get("const") + && let Some(literal) = literal_type_from_value(constant) + { + return Ok(literal); + } + + if let Some(any_of) = schema.get("anyOf").and_then(Value::as_array) { + return self.compile_union(any_of, hint_name, scope); + } + if let Some(one_of) = schema.get("oneOf").and_then(Value::as_array) { + return self.compile_union(one_of, hint_name, scope); + } + if let Some(all_of) = schema.get("allOf").and_then(Value::as_array) { + return self.compile_union(all_of, hint_name, scope); + } + + if let Some(type_name) = schema.get("type").and_then(Value::as_str) { + return self.type_from_keyword(type_name, schema, hint_name, scope); + } + + if let Some(type_array) = schema.get("type").and_then(Value::as_array) { + let mut choices = Vec::new(); + for keyword in type_array { + let Some(keyword) = keyword.as_str() else { + continue; + }; + choices.push(self.type_from_keyword(keyword, schema, hint_name, scope)?); + } + return Ok(match choices.len() { + 0 => TypeIR::top(), + 1 => choices.remove(0), + _ => TypeIR::union(choices), + }); + } + + if schema.get("properties").is_some() || schema.get("additionalProperties").is_some() { + return self.compile_object(schema, hint_name, scope); + } + + if schema.get("items").is_some() { + return self.compile_array(schema, hint_name, scope); + } + + Ok(TypeIR::top()) + } + + fn compile_ref(&mut self, reference: &str, scope: &str) -> Result { + let Some(ref_name) = parse_local_ref_name(reference) else { + return Err(format!( + "unsupported non-local JSON schema reference: {reference}" + )); + }; + + let scoped_ref = format!("{scope}::{ref_name}"); + + let alias_name = if let Some(existing) = self.def_aliases.get(&scoped_ref) { + existing.clone() + } else { + let fresh = self.unique_name(&ref_name); + self.def_aliases.insert(scoped_ref.clone(), fresh.clone()); + fresh + }; + + if let Some(compiled) = self.compiled_defs.get(&scoped_ref) { + return Ok(compiled.clone()); + } + + if self.in_progress_defs.contains(&scoped_ref) { + return Ok(TypeIR::class(alias_name)); + } + + let def_schema = self + .defs_by_scope + .get(&scoped_ref) + .ok_or_else(|| format!("reference `{reference}` not found in this field scope"))? + .clone(); + + self.in_progress_defs.insert(scoped_ref.clone()); + let compiled = self.compile_schema(&def_schema, Some(&alias_name), scope)?; + self.in_progress_defs.remove(&scoped_ref); + self.compiled_defs.insert(scoped_ref, compiled.clone()); + + Ok(compiled) + } + + fn compile_union( + &mut self, + variants: &[Value], + hint_name: Option<&str>, + scope: &str, + ) -> Result { + let mut choices = Vec::new(); + for (idx, variant_schema) in variants.iter().enumerate() { + let child_hint = hint_name.map(|hint| format!("{hint}Variant{}", idx + 1)); + let variant_type = self.compile_schema(variant_schema, child_hint.as_deref(), scope)?; + choices.push(variant_type); + } + + Ok(match choices.len() { + 0 => TypeIR::top(), + 1 => choices.remove(0), + _ => TypeIR::union(choices), + }) + } + + fn compile_enum( + &mut self, + schema: &Value, + values: &[Value], + hint_name: Option<&str>, + ) -> Result { + let all_strings = values.iter().all(|value| value.is_string()); + if all_strings { + let enum_base_name = schema + .get("title") + .and_then(Value::as_str) + .or(hint_name) + .unwrap_or("Enum"); + let enum_name = self.unique_name(enum_base_name); + let enum_description = schema + .get("description") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + + let mut enum_values = Vec::new(); + for value in values { + let variant_name = value + .as_str() + .ok_or_else(|| "string enum value unexpectedly missing".to_string())? + .to_string(); + enum_values.push((Name::new(variant_name), None)); + } + + self.registry.register_enum(Enum { + name: Name::new(enum_name.clone()), + description: enum_description, + values: enum_values, + constraints: Vec::new(), + }); + + return Ok(TypeIR::r#enum(&enum_name)); + } + + let mut literals = Vec::new(); + for value in values { + if let Some(literal) = literal_type_from_value(value) { + literals.push(literal); + } + } + + Ok(match literals.len() { + 0 => TypeIR::top(), + 1 => literals.remove(0), + _ => TypeIR::union(literals), + }) + } + + fn compile_array( + &mut self, + schema: &Value, + hint_name: Option<&str>, + scope: &str, + ) -> Result { + if let Some(prefix_items) = schema.get("prefixItems").and_then(Value::as_array) { + let mut members = Vec::new(); + for (idx, item_schema) in prefix_items.iter().enumerate() { + let child_hint = hint_name.map(|hint| format!("{hint}TupleItem{}", idx + 1)); + members.push(self.compile_schema(item_schema, child_hint.as_deref(), scope)?); + } + + if let Some(items_schema) = schema.get("items") { + match items_schema { + Value::Bool(false) => {} + Value::Bool(true) => members.push(TypeIR::string()), + other => { + let child_hint = hint_name.map(|hint| format!("{hint}TupleRest")); + members.push(self.compile_schema(other, child_hint.as_deref(), scope)?); + } + } + } + + return Ok(TypeIR::list(match members.len() { + 0 => TypeIR::string(), + 1 => members.remove(0), + _ => TypeIR::union(members), + })); + } + + if let Some(items) = schema.get("items").and_then(Value::as_array) { + let mut tuple_members = Vec::new(); + for (idx, item_schema) in items.iter().enumerate() { + let child_hint = hint_name.map(|hint| format!("{hint}Item{}", idx + 1)); + tuple_members.push(self.compile_schema( + item_schema, + child_hint.as_deref(), + scope, + )?); + } + return Ok(TypeIR::list(match tuple_members.len() { + 0 => TypeIR::top(), + 1 => tuple_members.remove(0), + _ => TypeIR::union(tuple_members), + })); + } + + if let Some(item_schema) = schema.get("items") { + let child_hint = hint_name.map(|hint| format!("{hint}Item")); + let inner = self.compile_schema(item_schema, child_hint.as_deref(), scope)?; + return Ok(TypeIR::list(inner)); + } + + Ok(TypeIR::list(TypeIR::string())) + } + + fn compile_object( + &mut self, + schema: &Value, + hint_name: Option<&str>, + scope: &str, + ) -> Result { + if let Some(properties) = schema.get("properties").and_then(Value::as_object) { + let class_base_name = schema + .get("title") + .and_then(Value::as_str) + .or(hint_name) + .unwrap_or("Object"); + let class_name = self.unique_name(class_base_name); + let class_description = schema + .get("description") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + + let required = schema + .get("required") + .and_then(Value::as_array) + .map(|required_fields| { + required_fields + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(); + + let mut class_fields = Vec::new(); + for (prop_name, prop_schema) in properties { + let child_hint = format!("{}{}", class_name, sanitize_identifier(prop_name)); + let mut prop_type = self.compile_schema(prop_schema, Some(&child_hint), scope)?; + if !required.contains(prop_name) { + prop_type = TypeIR::optional(prop_type); + } + + let description = prop_schema + .get("description") + .and_then(Value::as_str) + .map(ToOwned::to_owned); + + class_fields.push((Name::new(prop_name.clone()), prop_type, description, false)); + } + + self.registry.register_class(Class { + name: Name::new(class_name.clone()), + description: class_description, + namespace: StreamingMode::NonStreaming, + fields: class_fields, + constraints: Vec::new(), + streaming_behavior: default_streaming_behavior(), + }); + + return Ok(TypeIR::class(class_name)); + } + + if let Some(additional_props) = schema.get("additionalProperties") { + if matches!(additional_props, Value::Bool(false)) { + let class_name = self.unique_name( + schema + .get("title") + .and_then(Value::as_str) + .or(hint_name) + .unwrap_or("Object"), + ); + self.registry.register_class(Class { + name: Name::new(class_name.clone()), + description: schema + .get("description") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + namespace: StreamingMode::NonStreaming, + fields: Vec::new(), + constraints: Vec::new(), + streaming_behavior: default_streaming_behavior(), + }); + return Ok(TypeIR::class(class_name)); + } + + if matches!(additional_props, Value::Bool(true)) { + return Ok(TypeIR::map(TypeIR::string(), TypeIR::top())); + } + + let value_hint = hint_name.map(|hint| format!("{hint}Value")); + let value_type = self.compile_schema(additional_props, value_hint.as_deref(), scope)?; + return Ok(TypeIR::map(TypeIR::string(), value_type)); + } + + Ok(TypeIR::map(TypeIR::string(), TypeIR::top())) + } + + fn type_from_keyword( + &mut self, + keyword: &str, + schema: &Value, + hint_name: Option<&str>, + scope: &str, + ) -> Result { + match keyword { + "string" => Ok(TypeIR::string()), + "integer" => Ok(TypeIR::int()), + "number" => Ok(TypeIR::float()), + "boolean" => Ok(TypeIR::bool()), + "null" => Ok(TypeIR::null()), + "array" => self.compile_array(schema, hint_name, scope), + "object" => self.compile_object(schema, hint_name, scope), + _ => Ok(TypeIR::top()), + } + } +} + +fn parse_local_ref_name(reference: &str) -> Option { + let body = reference + .strip_prefix("#/$defs/") + .or_else(|| reference.strip_prefix("#/definitions/"))?; + body.split('/').next().map(ToOwned::to_owned) +} + +fn sanitize_identifier(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for ch in raw.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + } else { + out.push('_'); + } + } + + if out.is_empty() { + return "Type".to_string(); + } + + if out + .chars() + .next() + .map(|ch| ch.is_ascii_digit()) + .unwrap_or(false) + { + out.insert(0, 'T'); + } + + out +} + +fn literal_type_from_value(value: &Value) -> Option { + if let Some(str_value) = value.as_str() { + return Some(TypeIR::literal_string(str_value.to_string())); + } + if let Some(int_value) = value.as_i64() { + return Some(TypeIR::literal_int(int_value)); + } + if let Some(bool_value) = value.as_bool() { + return Some(TypeIR::literal_bool(bool_value)); + } + None +} + +fn compile_spec(spec: &AdapterSpec) -> Result { + let mut compiler = SchemaCompiler::default(); + + for field in &spec.output_fields { + let scope = SchemaCompiler::scope_id(&field.name); + compiler.add_scope_defs(&scope, &field.schema); + } + + let mut output_fields = Vec::new(); + for field in &spec.output_fields { + let scope = SchemaCompiler::scope_id(&field.name); + let type_ir = compiler.compile_schema(&field.schema, Some(&field.name), &scope)?; + output_fields.push(CompiledOutputField { + name: field.name.clone(), + description: field.description.clone(), + type_ir, + }); + } + + let output_class_name = compiler.unique_name("DSPyOutput"); + let output_class_fields = output_fields + .iter() + .map(|field| { + ( + Name::new(field.name.clone()), + field.type_ir.clone(), + if field.description.is_empty() { + None + } else { + Some(field.description.clone()) + }, + false, + ) + }) + .collect::>(); + + compiler.registry.register_class(Class { + name: Name::new(output_class_name.clone()), + description: if spec.instruction.is_empty() { + None + } else { + Some(spec.instruction.clone()) + }, + namespace: StreamingMode::NonStreaming, + fields: output_class_fields, + constraints: Vec::new(), + streaming_behavior: default_streaming_behavior(), + }); + + let output_format = compiler.registry.build(TypeIR::class(output_class_name)); + + Ok(CompiledSpec { + input_fields: spec.input_fields.clone(), + output_fields, + output_format, + }) +} + +fn simplify_type_name(raw: &str) -> String { + let mut result = String::with_capacity(raw.len()); + let mut chars = raw.chars(); + while let Some(ch) = chars.next() { + if ch == '`' { + let mut token = String::new(); + for next in chars.by_ref() { + if next == '`' { + break; + } + token.push(next); + } + let simplified = token.rsplit("::").next().unwrap_or(&token); + result.push_str(simplified); + } else { + result.push(ch); + } + } + result +} + +fn render_type_name_for_prompt(type_ir: &TypeIR) -> String { + let raw = type_ir.diagnostic_repr().to_string(); + let simplified = simplify_type_name(&raw); + simplified + .replace("class ", "") + .replace("enum ", "") + .replace(" | ", " or ") + .trim() + .to_string() +} + +fn split_schema_definitions(schema: &str) -> Option<(String, String)> { + let lines: Vec<&str> = schema.lines().collect(); + let mut index = 0; + let mut definitions = Vec::new(); + let mut parsed_any = false; + + while index < lines.len() { + let start_index = index; + + while index < lines.len() && lines[index].trim().is_empty() { + index += 1; + } + + while index < lines.len() && lines[index].trim_start().starts_with("//") { + index += 1; + } + + while index < lines.len() && lines[index].trim().is_empty() { + index += 1; + } + + if index >= lines.len() { + break; + } + + let name_line = lines[index].trim(); + if name_line.is_empty() { + break; + } + index += 1; + + if index >= lines.len() || lines[index].trim() != "----" { + index = start_index; + break; + } + index += 1; + + let mut values_found = 0; + while index < lines.len() { + let trimmed = lines[index].trim_start(); + if trimmed.is_empty() { + break; + } + if trimmed.starts_with('-') { + values_found += 1; + index += 1; + continue; + } + break; + } + + if values_found == 0 { + index = start_index; + break; + } + + let mut block_end = index; + if index < lines.len() && lines[index].trim().is_empty() { + index += 1; + block_end = index; + } + + definitions.extend_from_slice(&lines[start_index..block_end]); + parsed_any = true; + } + + if !parsed_any { + return None; + } + + let mut main_lines = Vec::new(); + if index < lines.len() { + main_lines.extend_from_slice(&lines[index..]); + } + + let defs = definitions.join("\n").trim_end().to_string(); + let main = main_lines.join("\n").trim_start().to_string(); + if defs.is_empty() || main.is_empty() { + None + } else { + Some((defs, main)) + } +} + +fn format_schema_for_prompt(schema: &str) -> String { + let Some((definitions, main)) = split_schema_definitions(schema) else { + return schema.to_string(); + }; + + format!("Definitions (used below):\n\n{definitions}\n\n{main}") +} + +/// One leading `Name` / `----` / `- value…` definition block in a rendered +/// field schema, with the source line indices it spans (including any leading +/// `//` comment describing it and one trailing blank line). +struct DefinitionBlock { + name: String, + line_indices: Vec, +} + +/// Parse the contiguous run of enum/class definition blocks at the top of a +/// rendered field schema. Returns the blocks and the index where the main body +/// (the part that actually references them) begins. +fn parse_leading_definition_blocks(lines: &[&str]) -> (Vec, usize) { + let mut blocks = Vec::new(); + let mut index = 0; + + loop { + let block_start = index; + let mut cursor = index; + while cursor < lines.len() && lines[cursor].trim().is_empty() { + cursor += 1; + } + while cursor < lines.len() && lines[cursor].trim_start().starts_with("//") { + cursor += 1; + } + while cursor < lines.len() && lines[cursor].trim().is_empty() { + cursor += 1; + } + if cursor >= lines.len() { + break; + } + let name = lines[cursor].trim().to_string(); + if name.is_empty() || cursor + 1 >= lines.len() || lines[cursor + 1].trim() != "----" { + break; + } + cursor += 2; + let mut values = 0; + while cursor < lines.len() { + let trimmed = lines[cursor].trim_start(); + if trimmed.is_empty() { + break; + } + if trimmed.starts_with('-') { + values += 1; + cursor += 1; + continue; + } + break; + } + if values == 0 { + break; + } + let mut block_end = cursor; + if block_end < lines.len() && lines[block_end].trim().is_empty() { + block_end += 1; + } + blocks.push(DefinitionBlock { + name, + line_indices: (block_start..block_end).collect(), + }); + index = block_end; + } + + (blocks, index) +} + +/// BAML renders a field against the whole signature's type registry, so it +/// prepends EVERY named enum/class definition to each field's schema — even +/// fields (including primitive `string` fields) that reference none of them. +/// Drop the definition blocks this field's body does not actually use, so each +/// field carries only its own `Definitions (used below)`. +fn strip_unreferenced_definitions(schema: &str) -> String { + let lines: Vec<&str> = schema.lines().collect(); + let (blocks, main_start) = parse_leading_definition_blocks(&lines); + if blocks.is_empty() { + return schema.to_string(); + } + + // A definition is referenced when its name appears (as a whole word) in an + // actual type position — a non-comment body line such as `role: Role,` — + // not merely inside a `//` description. Grow the referencing text with kept + // blocks so transitive references between definitions survive. + let mut referencing = lines[main_start..] + .iter() + .filter(|line| !line.trim_start().starts_with("//")) + .copied() + .collect::>() + .join("\n"); + + let mut kept = vec![false; blocks.len()]; + loop { + let mut changed = false; + for (idx, block) in blocks.iter().enumerate() { + if kept[idx] { + continue; + } + let pattern = format!(r"\b{}\b", regex::escape(&block.name)); + let referenced = Regex::new(&pattern) + .map(|re| re.is_match(&referencing)) + .unwrap_or(true); // on the impossible regex error, keep the block + if referenced { + kept[idx] = true; + changed = true; + for &line_index in &block.line_indices { + referencing.push('\n'); + referencing.push_str(lines[line_index]); + } + } + } + if !changed { + break; + } + } + + let mut out: Vec<&str> = Vec::new(); + for (idx, block) in blocks.iter().enumerate() { + if kept[idx] { + for &line_index in &block.line_indices { + out.push(lines[line_index]); + } + } + } + out.extend_from_slice(&lines[main_start..]); + out.join("\n").trim().to_string() +} + +fn render_field_type_schema( + parent_format: &OutputFormatContent, + type_ir: &TypeIR, +) -> Result { + let field_format = OutputFormatContent { + enums: parent_format.enums.clone(), + classes: parent_format.classes.clone(), + recursive_classes: parent_format.recursive_classes.clone(), + structural_recursive_aliases: parent_format.structural_recursive_aliases.clone(), + target: type_ir.clone(), + }; + + let schema = field_format + .render(RenderOptions::default().with_prefix(None)) + .map_err(|err| err.to_string())? + .unwrap_or_else(|| type_ir.diagnostic_repr().to_string()); + + Ok(strip_unreferenced_definitions(&schema)) +} + +fn render_field_structure_core(spec: &AdapterSpec) -> Result { + let compiled = compile_spec(spec)?; + let mut lines = vec![ + "All interactions will be structured in the following way, with the appropriate values filled in.".to_string(), + String::new(), + ]; + + for field in &compiled.input_fields { + lines.push(format!("[[ ## {} ## ]]", field.name)); + lines.push(field.name.clone()); + lines.push(String::new()); + } + + for field in &compiled.output_fields { + let type_name = render_type_name_for_prompt(&field.type_ir); + let schema = render_field_type_schema(&compiled.output_format, &field.type_ir)?; + + lines.push(format!("[[ ## {} ## ]]", field.name)); + lines.push(format!( + "Output field `{}` should be of type: {type_name}", + field.name + )); + + if !schema.is_empty() && schema != type_name { + lines.push(String::new()); + lines.push(format_schema_for_prompt(&schema)); + } + + lines.push(String::new()); + } + + lines.push("[[ ## completed ## ]]".to_string()); + Ok(lines.join("\n")) +} + +fn parse_sections(content: &str) -> HashMap { + let mut sections: Vec<(Option, Vec)> = vec![(None, Vec::new())]; + + for line in content.lines() { + let trimmed = line.trim(); + if let Some(captures) = FIELD_HEADER_PATTERN.captures(trimmed) { + let header = captures + .get(1) + .map(|value| value.as_str().to_string()) + .unwrap_or_default(); + let marker = captures + .get(0) + .expect("header capture should include full marker"); + let remaining = trimmed[marker.end()..].trim(); + + let mut field_lines = Vec::new(); + if !remaining.is_empty() { + field_lines.push(remaining.to_string()); + } + sections.push((Some(header), field_lines)); + } else if let Some((_, lines)) = sections.last_mut() { + lines.push(line.to_string()); + } + } + + let mut parsed = HashMap::new(); + for (header, lines) in sections { + let Some(name) = header else { + continue; + }; + parsed + .entry(name) + .or_insert_with(|| lines.join("\n").trim().to_string()); + } + + parsed +} + +fn parse_response_core( + spec: &AdapterSpec, + completion: &str, + is_done: bool, +) -> Result, String> { + let compiled = compile_spec(spec)?; + let sections = parse_sections(completion); + + let mut parsed_output = Map::new(); + let mut errors = Vec::new(); + + for field in &compiled.output_fields { + let Some(raw_text) = sections.get(&field.name) else { + errors.push(format!( + "missing output field `{}` in LM response", + field.name + )); + continue; + }; + + let parsed = match bamltype::jsonish::from_str( + &compiled.output_format, + &field.type_ir, + raw_text, + is_done, + ) { + Ok(value) => value, + Err(err) => { + errors.push(format!( + "failed to parse output field `{}` with JSONish: {err}", + field.name + )); + continue; + } + }; + + let baml_value: BamlValue = parsed.into(); + let json_value = serde_json::to_value(baml_value).map_err(|err| err.to_string())?; + parsed_output.insert(field.name.clone(), json_value); + } + + if errors.is_empty() { + Ok(parsed_output) + } else { + Err(errors.join("\n")) + } +} + +#[pyfunction] +fn render_field_structure(spec_json: &str) -> PyResult { + let spec: AdapterSpec = serde_json::from_str(spec_json) + .map_err(|err| PyValueError::new_err(format!("invalid adapter spec JSON: {err}")))?; + render_field_structure_core(&spec) + .map_err(|err| PyValueError::new_err(format!("failed to render field structure: {err}"))) +} + +#[pyfunction(signature = (spec_json, completion, is_done = true))] +fn parse_response(spec_json: &str, completion: &str, is_done: bool) -> PyResult { + let spec: AdapterSpec = serde_json::from_str(spec_json) + .map_err(|err| PyValueError::new_err(format!("invalid adapter spec JSON: {err}")))?; + let parsed = parse_response_core(&spec, completion, is_done) + .map_err(|err| PyValueError::new_err(format!("failed to parse response: {err}")))?; + + serde_json::to_string(&Value::Object(parsed)) + .map_err(|err| PyValueError::new_err(format!("failed to serialize parsed response: {err}"))) +} + +#[pymodule] +fn _dsrs_dspy(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(render_field_structure, m)?)?; + m.add_function(wrap_pyfunction!(parse_response, m)?)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn nested_spec() -> AdapterSpec { + serde_json::from_value(json!({ + "input_fields": [ + { + "name": "note", + "description": "Clinical note", + "schema": {"type": "string"} + } + ], + "output_fields": [ + { + "name": "patient", + "description": "Extracted patient object", + "schema": { + "$defs": { + "Address": { + "type": "object", + "title": "Address", + "properties": { + "street": {"type": "string"} + }, + "required": ["street"] + } + }, + "type": "object", + "title": "Patient", + "properties": { + "name": {"type": "string"}, + "age": { + "anyOf": [ + {"type": "integer"}, + {"type": "null"} + ] + }, + "address": {"$ref": "#/$defs/Address"} + }, + "required": ["name", "age", "address"] + } + } + ], + "instruction": "Extract patient information" + })) + .expect("valid test spec") + } + + #[test] + fn render_field_structure_uses_baml_schema() { + let spec = nested_spec(); + let rendered = render_field_structure_core(&spec).expect("render should succeed"); + + assert!(rendered.contains("[[ ## patient ## ]]")); + assert!(rendered.contains("Output field `patient` should be of type")); + assert!(rendered.contains("patient")); + } + + #[test] + fn parse_response_uses_jsonish_for_nested_object() { + let spec = nested_spec(); + let completion = r#" +[[ ## patient ## ]] +{ + "name": "Ada", + "age": 30, + "address": { + "street": "Main", + }, +} +[[ ## completed ## ]] +"#; + + let parsed = parse_response_core(&spec, completion, true).expect("parse should succeed"); + + assert_eq!(parsed["patient"]["name"], "Ada"); + assert_eq!(parsed["patient"]["age"], 30); + assert_eq!(parsed["patient"]["address"]["street"], "Main"); + } + + #[test] + fn parse_response_tolerates_marker_whitespace_variants() { + // deepseek-v4-flash emits `[[ ## name ##]]` (missing space) on a real fraction of calls; + // strict marker matching turned those into missing-field hard failures. + let spec = nested_spec(); + let completion = "[[ ## patient ##]]\n{\"name\": \"Ada\", \"age\": 30, \"address\": {\"street\": \"Main\"}}\n[[##completed##]]\n"; + + let parsed = parse_response_core(&spec, completion, true).expect("parse should succeed"); + + assert_eq!(parsed["patient"]["name"], "Ada"); + assert_eq!(parsed["patient"]["age"], 30); + } + + #[test] + fn parse_response_preserves_python_none_as_null_in_nested_output() { + let spec = nested_spec(); + let completion = r#" +[[ ## patient ## ]] +{'name': 'Ada', 'age': None, 'address': {'street': 'Main'}} +[[ ## completed ## ]] +"#; + + let parsed = parse_response_core(&spec, completion, true).expect("parse should succeed"); + + assert_eq!(parsed["patient"]["age"], Value::Null); + } + + #[test] + fn parse_response_reports_missing_field() { + let spec: AdapterSpec = serde_json::from_value(json!({ + "output_fields": [ + { + "name": "answer", + "description": "", + "schema": {"type": "string"} + } + ] + })) + .expect("valid simple spec"); + + let err = parse_response_core(&spec, "[[ ## completed ## ]]", true) + .expect_err("should fail due to missing marker"); + + assert!(err.contains("missing output field `answer`")); + } +} + +#[cfg(test)] +mod property_tests; diff --git a/crates/dspy-py/src/property_tests.rs b/crates/dspy-py/src/property_tests.rs new file mode 100644 index 0000000..c4af4c0 --- /dev/null +++ b/crates/dspy-py/src/property_tests.rs @@ -0,0 +1,624 @@ +use std::collections::HashMap; +use std::ffi::CString; + +use proptest::prelude::*; +use proptest::string::string_regex; +use pyo3::prelude::*; +use pyo3::types::PyDict; +use serde_json::{Map, Value, json}; + +use super::{ + AdapterField, AdapterSpec, CompiledSpec, TypeIR, compile_spec, parse_response_core, + render_field_structure_core, +}; + +#[derive(Clone, Debug)] +enum FuzzType { + Str, + Int, + Float, + Bool, + LiteralStr(String), + LiteralInt(i64), + LiteralBool(bool), + Enum(Vec), + List(Box), + Dict(Box), + Optional(Box), + Union(Vec), + Object(Vec), +} + +#[derive(Clone, Debug)] +struct FuzzObjectField { + name: String, + description: String, + required: bool, + ty: FuzzType, +} + +#[derive(Clone, Debug)] +struct FuzzOutputCase { + name: String, + description: String, + ty: FuzzType, + use_null_if_optional: bool, + loose_jsonish: bool, +} + +fn description_strategy() -> BoxedStrategy { + string_regex("[A-Za-z0-9 _-]{3,24}") + .expect("valid description regex") + .boxed() +} + +fn dedupe_non_empty_strings(mut values: Vec) -> Vec { + values.sort(); + values.dedup(); + if values.is_empty() { + return vec!["alpha".to_string(), "beta".to_string()]; + } + if values.len() == 1 { + values.push(format!("{}_alt", values[0])); + } + values +} + +fn leaf_type_strategy() -> BoxedStrategy { + prop_oneof![ + Just(FuzzType::Str), + Just(FuzzType::Int), + Just(FuzzType::Float), + Just(FuzzType::Bool), + string_regex("[a-zA-Z0-9_-]{1,20}") + .expect("valid literal string regex") + .prop_map(FuzzType::LiteralStr), + any::().prop_map(|n| FuzzType::LiteralInt(n as i64)), + any::().prop_map(FuzzType::LiteralBool), + prop::collection::vec( + string_regex("[a-z]{1,10}").expect("valid enum value regex"), + 2..6, + ) + .prop_map(|values| FuzzType::Enum(dedupe_non_empty_strings(values))), + ] + .boxed() +} + +fn fuzz_type_strategy() -> BoxedStrategy { + leaf_type_strategy() + .prop_recursive(4, 128, 8, |inner| { + prop_oneof![ + inner.clone().prop_map(|ty| FuzzType::List(Box::new(ty))), + inner.clone().prop_map(|ty| FuzzType::Dict(Box::new(ty))), + inner + .clone() + .prop_map(|ty| FuzzType::Optional(Box::new(ty))), + prop::collection::vec(inner.clone(), 2..5).prop_map(FuzzType::Union), + prop::collection::vec( + ( + inner.clone(), + any::(), + prop::option::of(description_strategy()), + ), + 1..5, + ) + .prop_map(|items| { + let fields = items + .into_iter() + .enumerate() + .map(|(idx, (ty, required, description))| FuzzObjectField { + name: format!("field_{}", idx + 1), + description: description.unwrap_or_default(), + required, + ty, + }) + .collect(); + FuzzType::Object(fields) + }), + ] + .boxed() + }) + .boxed() +} + +fn output_cases_strategy() -> BoxedStrategy> { + prop::collection::vec( + ( + fuzz_type_strategy(), + any::(), + any::(), + prop::option::of(description_strategy()), + ), + 1..5, + ) + .prop_map(|entries| { + entries + .into_iter() + .enumerate() + .map( + |(idx, (ty, use_null_if_optional, loose_jsonish, description))| FuzzOutputCase { + name: format!("output_{}", idx + 1), + description: description.unwrap_or_default(), + ty, + use_null_if_optional, + loose_jsonish, + }, + ) + .collect() + }) + .boxed() +} + +fn schema_from_type(ty: &FuzzType, hint: &str) -> Value { + match ty { + FuzzType::Str => json!({"type": "string"}), + FuzzType::Int => json!({"type": "integer"}), + FuzzType::Float => json!({"type": "number"}), + FuzzType::Bool => json!({"type": "boolean"}), + FuzzType::LiteralStr(value) => json!({"const": value}), + FuzzType::LiteralInt(value) => json!({"const": value}), + FuzzType::LiteralBool(value) => json!({"const": value}), + FuzzType::Enum(values) => json!({ + "title": format!("{hint}Enum"), + "type": "string", + "enum": values, + }), + FuzzType::List(inner) => json!({ + "type": "array", + "items": schema_from_type(inner, &format!("{hint}Item")), + }), + FuzzType::Dict(value_type) => json!({ + "type": "object", + "additionalProperties": schema_from_type(value_type, &format!("{hint}Value")), + }), + FuzzType::Optional(inner) => json!({ + "anyOf": [ + schema_from_type(inner, &format!("{hint}Some")), + {"type": "null"}, + ] + }), + FuzzType::Union(choices) => json!({ + "anyOf": choices + .iter() + .enumerate() + .map(|(idx, choice)| schema_from_type(choice, &format!("{hint}Variant{}", idx + 1))) + .collect::>() + }), + FuzzType::Object(fields) => { + let mut properties = Map::new(); + let mut required = Vec::new(); + + for field in fields { + let mut field_schema = + schema_from_type(&field.ty, &format!("{hint}{}", field.name.replace('_', ""))); + + if !field.description.is_empty() + && let Value::Object(map) = &mut field_schema + { + map.insert( + "description".to_string(), + Value::String(field.description.clone()), + ); + } + + if field.required { + required.push(field.name.clone()); + } + properties.insert(field.name.clone(), field_schema); + } + + json!({ + "title": format!("{hint}Object"), + "description": format!("{hint} object"), + "type": "object", + "properties": properties, + "required": required, + }) + } + } +} + +fn value_from_type(ty: &FuzzType, use_null_if_optional: bool) -> Value { + match ty { + FuzzType::Str => Value::String("plain text value".to_string()), + FuzzType::Int => json!(42), + FuzzType::Float => json!(3.25), + FuzzType::Bool => json!(true), + FuzzType::LiteralStr(value) => Value::String(value.clone()), + FuzzType::LiteralInt(value) => json!(value), + FuzzType::LiteralBool(value) => json!(value), + FuzzType::Enum(values) => Value::String(values[0].clone()), + FuzzType::List(inner) => Value::Array(vec![ + value_from_type(inner, false), + value_from_type(inner, false), + ]), + FuzzType::Dict(inner) => { + let mut map = Map::new(); + map.insert("k1".to_string(), value_from_type(inner, false)); + map.insert("k2".to_string(), value_from_type(inner, false)); + Value::Object(map) + } + FuzzType::Optional(inner) => { + if use_null_if_optional { + Value::Null + } else { + value_from_type(inner, false) + } + } + FuzzType::Union(choices) => value_from_type(&choices[0], use_null_if_optional), + FuzzType::Object(fields) => { + let mut map = Map::new(); + for field in fields { + map.insert( + field.name.clone(), + value_from_type(&field.ty, use_null_if_optional), + ); + } + Value::Object(map) + } + } +} + +fn loosen_top_level_jsonish(text: String, value: &Value) -> String { + if value.is_object() { + return text.replacen("\n}", ",\n}", 1); + } + if value.is_array() { + return text.replacen("\n]", ",\n]", 1); + } + text +} + +fn completion_text_for_value(value: &Value, loose_jsonish: bool) -> String { + match value { + Value::String(text) => text.clone(), + Value::Number(number) => number.to_string(), + Value::Bool(flag) => flag.to_string(), + Value::Null => "null".to_string(), + _ => { + let text = serde_json::to_string_pretty(value).expect("valid JSON value serialization"); + if loose_jsonish { + loosen_top_level_jsonish(text, value) + } else { + text + } + } + } +} + +fn build_spec_completion_and_expected( + cases: &[FuzzOutputCase], +) -> (AdapterSpec, String, Map) { + let output_fields = cases + .iter() + .map(|case| AdapterField { + name: case.name.clone(), + description: case.description.clone(), + format: None, + schema: schema_from_type(&case.ty, &case.name), + }) + .collect::>(); + + let mut expected = Map::new(); + let mut sections = Vec::new(); + + for case in cases { + let value = value_from_type(&case.ty, case.use_null_if_optional); + expected.insert(case.name.clone(), value.clone()); + + sections.push(format!( + "[[ ## {} ## ]]\n{}", + case.name, + completion_text_for_value(&value, case.loose_jsonish) + )); + } + + let completion = format!("{}\n\n[[ ## completed ## ]]\n", sections.join("\n\n")); + + ( + AdapterSpec { + input_fields: Vec::new(), + output_fields, + instruction: "property test instruction".to_string(), + }, + completion, + expected, + ) +} + +fn target_output_class( + compiled: &CompiledSpec, +) -> Option<&bamltype::internal_baml_jinja::types::Class> { + let TypeIR::Class { name, mode, .. } = &compiled.output_format.target else { + return None; + }; + + compiled.output_format.classes.get(&(name.clone(), *mode)) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 256, .. ProptestConfig::default() })] + + #[test] + fn prop_generated_schemas_compile_render_parse_and_preserve_descriptions( + cases in output_cases_strategy() + ) { + let (spec, completion, expected) = build_spec_completion_and_expected(&cases); + + let compiled = compile_spec(&spec) + .unwrap_or_else(|err| panic!("compile_spec failed for generated case: {err}\nSpec: {:?}", spec)); + + let rendered = render_field_structure_core(&spec) + .unwrap_or_else(|err| panic!("render_field_structure_core failed: {err}\nSpec: {:?}", spec)); + + for case in &cases { + prop_assert!( + rendered.contains(&format!("[[ ## {} ## ]]", case.name)), + "rendered field structure missing output marker for {}\nRendered:\n{}", + case.name, + rendered, + ); + } + + let parsed = parse_response_core(&spec, &completion, true) + .unwrap_or_else(|err| panic!("parse_response_core failed: {err}\nCompletion:\n{completion}\nSpec: {:?}", spec)); + + prop_assert_eq!(parsed, expected, "parsed output mismatch for generated case"); + + let class = target_output_class(&compiled) + .unwrap_or_else(|| panic!("expected class target in compiled output format")); + + let mut class_field_descs: HashMap = HashMap::new(); + for (name, _, description, _) in &class.fields { + class_field_descs.insert( + name.real_name().to_string(), + description.clone().unwrap_or_default(), + ); + } + + for case in &cases { + let got = class_field_descs + .get(&case.name) + .cloned() + .unwrap_or_default(); + prop_assert_eq!( + got, + case.description.clone(), + "output field description should be preserved for {}", + case.name + ); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 128, .. ProptestConfig::default() })] + + #[test] + fn prop_defs_and_refs_parse_correctly_with_jsonish_noise( + person_name in string_regex("[A-Za-z]{3,20}").expect("valid person name regex"), + street in string_regex("[A-Za-z0-9 ]{3,24}").expect("valid street regex"), + city in string_regex("[A-Za-z]{3,20}").expect("valid city regex"), + postal in 10000u32..99999u32, + age in 1u8..110u8, + loose_jsonish in any::(), + ) { + let schema = json!({ + "$defs": { + "Address": { + "type": "object", + "title": "Address", + "description": "Postal address", + "properties": { + "street": {"type": "string", "description": "Street line"}, + "city": {"type": "string", "description": "City"}, + "postal": {"type": "integer", "description": "Postal code"} + }, + "required": ["street", "city", "postal"] + }, + "Profile": { + "type": "object", + "title": "Profile", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer"}, + "address": {"$ref": "#/$defs/Address"} + }, + "required": ["name", "age", "address"] + } + }, + "$ref": "#/$defs/Profile" + }); + + let value = json!({ + "name": person_name, + "age": age, + "address": { + "street": street, + "city": city, + "postal": postal, + } + }); + + let spec = AdapterSpec { + input_fields: Vec::new(), + output_fields: vec![AdapterField { + name: "profile".to_string(), + description: "Nested profile payload".to_string(), + format: None, + schema, + }], + instruction: "Extract profile data".to_string(), + }; + + let rendered = render_field_structure_core(&spec) + .unwrap_or_else(|err| panic!("render failed: {err}")); + prop_assert!(rendered.contains("[[ ## profile ## ]]")); + + let completion = format!( + "[[ ## profile ## ]]\n{}\n\n[[ ## completed ## ]]\n", + completion_text_for_value(&value, loose_jsonish) + ); + + let parsed = parse_response_core(&spec, &completion, true) + .unwrap_or_else(|err| panic!("parse failed: {err}\nCompletion:\n{completion}")); + + prop_assert_eq!(parsed.get("profile"), Some(&value)); + } +} + +fn load_pydantic_typing_cases_from_python() -> Option> { + Python::initialize(); + + Python::attach(|py| { + if py.import("pydantic").is_err() { + return None; + } + + let code = CString::new( + r#" +import json +from datetime import datetime, date, time +from typing import Annotated, Literal, Optional, Union +from pydantic import BaseModel, Field, TypeAdapter + +class Address(BaseModel): + street: Annotated[str, Field(description='street line', min_length=1)] + zipcode: Annotated[int, Field(ge=1, le=99999)] + +class Contact(BaseModel): + email: str + phone: str | None = None + +class User(BaseModel): + name: Annotated[str, Field(description='full legal name')] + age: Annotated[int, Field(ge=0)] + active: bool + tags: list[str] + scores: dict[str, float] + address: Address + contact: Contact | None = None + +cases = [ + ('str', str, 'hello world', 'plain string'), + ('int', int, 42, 'plain int'), + ('float', float, 3.5, 'plain float'), + ('bool', bool, True, 'plain bool'), + ('annotated_str', Annotated[str, Field(description='annotated string', min_length=1)], 'annotated', 'annotated str'), + ('literal', Literal['alpha', 'beta'], 'alpha', 'literal'), + ('optional', Optional[int], None, 'optional int'), + ('union_int_str', Union[int, str], 7, 'union'), + ('list_annotated_int', list[Annotated[int, Field(ge=0, le=10)]], [1, 2, 3], 'list annotated int'), + ('dict_list_int', dict[str, list[int]], {'a': [1, 2], 'b': [3]}, 'dict of list[int]'), + ('tuple_int_str', tuple[int, str], [9, 'x'], 'tuple[int, str]'), + ('set_str', set[str], ['one', 'two'], 'set[str]'), + ('datetime', datetime, '2025-01-02T03:04:05', 'datetime string format'), + ('date', date, '2025-01-02', 'date string format'), + ('time', time, '03:04:05', 'time string format'), + ('address_model', Address, {'street': 'main', 'zipcode': 94105}, 'base model'), + ( + 'user_model', + User, + { + 'name': 'Ada', + 'age': 33, + 'active': True, + 'tags': ['ml'], + 'scores': {'q': 0.9}, + 'address': {'street': 'main', 'zipcode': 94105}, + 'contact': {'email': 'a@b.com', 'phone': None} + }, + 'nested base model', + ), +] + +rows = [] +for name, annotation, sample, description in cases: + schema = TypeAdapter(annotation).json_schema() + rows.append((name, json.dumps(schema), json.dumps(sample), description)) +"#, + ) + .ok()?; + + let locals = PyDict::new(py); + py.run(code.as_c_str(), None, Some(&locals)).ok()?; + let rows_obj = locals.get_item("rows").ok().flatten()?; + + let rows: Vec<(String, String, String, String)> = rows_obj.extract().ok()?; + let mut parsed_rows = Vec::with_capacity(rows.len()); + for (name, schema_json, sample_json, description) in rows { + let schema = serde_json::from_str::(&schema_json).ok()?; + let sample = serde_json::from_str::(&sample_json).ok()?; + parsed_rows.push((name, schema, sample, description)); + } + + Some(parsed_rows) + }) +} + +#[test] +fn pydantic_typing_matrix_from_python_typeadapter_roundtrips() { + let Some(cases) = load_pydantic_typing_cases_from_python() else { + eprintln!( + "Skipping Python-driven typing matrix test: `pydantic` not available in interpreter" + ); + return; + }; + + assert!( + !cases.is_empty(), + "Python-driven typing matrix should produce at least one case" + ); + + for (case_name, schema, sample, description) in cases { + let spec = AdapterSpec { + input_fields: Vec::new(), + output_fields: vec![AdapterField { + name: "value".to_string(), + description: description.clone(), + format: None, + schema, + }], + instruction: format!("typing case: {case_name}"), + }; + + let rendered = render_field_structure_core(&spec) + .unwrap_or_else(|err| panic!("render failed for `{case_name}`: {err}")); + assert!( + rendered.contains("[[ ## value ## ]]"), + "rendered structure missing field marker for `{case_name}`" + ); + + let completion = format!( + "[[ ## value ## ]]\n{}\n\n[[ ## completed ## ]]\n", + completion_text_for_value(&sample, true) + ); + + let parsed = parse_response_core(&spec, &completion, true).unwrap_or_else(|err| { + panic!( + "parse failed for `{case_name}`: {err}\nCompletion:\n{completion}\nRendered:\n{rendered}" + ) + }); + + assert_eq!( + parsed.get("value"), + Some(&sample), + "parsed value mismatch for `{case_name}`" + ); + + let compiled = compile_spec(&spec) + .unwrap_or_else(|err| panic!("compile_spec failed for `{case_name}`: {err}")); + let class = target_output_class(&compiled) + .unwrap_or_else(|| panic!("missing target output class for `{case_name}`")); + let field_desc = class + .fields + .iter() + .find(|(name, _, _, _)| name.real_name() == "value") + .and_then(|(_, _, desc, _)| desc.clone()) + .unwrap_or_default(); + assert_eq!( + field_desc, description, + "field description mismatch for `{case_name}`" + ); + } +} diff --git a/crates/dspy-py/tests/python/__pycache__/test_adapter.cpython-314-pytest-9.1.1.pyc b/crates/dspy-py/tests/python/__pycache__/test_adapter.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a69343a19f6d5d09a2657b9fe331383056171a57 GIT binary patch literal 16479 zcmeHOTX0iHnm$K5x*p3i;0q*xg$Wo1Bb$pccS6F&KyaKxA|?|%T4BjTi7d(I91qwj zGD>D@26ko&YAaPkZKZbouu~Ow^RSm}Rd(lvecH#BEnmplo!ZP+<{>Xr5J<6;r~Upu zR|yfrq$Wvi(U$xC-Tn9d@9w|9?%v!O3UV<2adGtZO${73LJre-4B}2J&v7MA;#BT6 zPU4UAs*8Qy{lbK+o$L3g-gZ62r}|ikU-jebIv(f`s=S3P!g{&7fNty;9=WbJ5m`pCS1p`P3G* zuqB;OMbxySDtjV_PfMqtJ#^~%L#bp@P0JA(Vfbv06qB-&jwCb5Y+g|#NdzQw;2xZe zkVi6?iv@!Ng-AZFo-fF6M3P7{Ux++^Dl&pFCGtO|cO!*-ZZa}jkjIj$n$2ez4A)yNE{m?$4-vnpaWfbXwJfk*qoy zR_|Y*abs^bH|X z+(6W?`4fqJa*PrSCK6+X)I^SG zWHUaI%o!o!L}D~6D{3yAPv;AXge)Suw#<>~=tO=5(OOVv6fHZFfKLlYHVdMEM1O*p|_9g zaujH|r2gWGw_mtTy>i>jHMYzM&9{Bz@N;7Lvhs`QjIj0d06g>_TY*FdqCvSCU%7>> zRUzkuWAIe_dLe>&;yoq?oVx`^( zg30m6!ro7sntvJoi|}+}uBo?l_-5Oai*22=ZJp&U$L8AlOUG`mT)nt*)9lJkm6cDI zPTZq9n6ggERdpf_EjRw}JO!)Xf+z7q2+>V9DH1NdtGserosjb~MUL`p;$VZY!=5r^ z68#SRD>S%E+&vUz&26&YZD$`g@~eiPRU8`pC2q)EnJwK21>gr*rZOd}&mFWj&=kis z7`YFwb@*3k1uAi$iceI;)n#F|o*SiTrYeADm8WJ|s`50jyIt$K>$45r?yQqg)}V~{>?rf=3Jg;k*{Uj1@e@lNs7bzP1+7X{3~ zp19D>SNVE|NT*=NQEOU9Y79!{F-v8B&+(FXFE6i`e2?3oMxfnq-*Ef;AGiOv+WvvZ z?fq^U)C{vsz@xOR zMmB6JPWDC>3ho`4TA{}^H-9YpRIBc3@1yi&Wm3`@Li1&d$uU;UrB&?s^oT!nO7)PnjjSDH?IdfLCa_}gnk$=+@>9!Mv5*d)5*5g>Dcac^g11VPRDM&o_f+-w<-i{Y>e23 zZRoToS(E89cwf5?QxyCwSFs-{aW})Qi{ULZ;VmCzD&d`_{!hd;Wg$Afaz>0U3dXM@ zM9bnD-8t`|Ertb(VYz0jT$VQogxnM(Wua@je@5(D6pUX*=qig5-8t`|Ertb(VYz0j zT$VQogxnO@mxb=>3o~N(qG0?gLU&nQuRG@*w8gMMF)Y_?mCN!5fsjwc6=k9QD$;LX z6pUX*Xuq^ecg@?R6}&+FmTR`kWqE@@$W3usSy*%Rdo$GB#;+o*xwKPv&D*3Eyg>Yx zYqrW|d4oX6O>xDQgO?AMg#(ukvVHb}MRCK7xB(vJjr++4y>WjonICCrMCOnTp3&cqAQNm2qzR=bPt&) zVqDxkWNmxR7~_Xcwc-e$L9L)zVB;$+65?a4FKp|+j%~p$2z0RS*4xtuTvA@>&XW zx4WvZ{IqR|`)i=Cz$5gar81k+02MX>)s^6`W9Uc6F_hUUMXwZ~emwE$z4!#{#U~&4 z;y2ujtIfW%zvH8WYi*ZF0X%IS|C@M_jnQA5Gg0O@4>?9fjM3F+PW(R{qmMA3N}(mk zv%UGOo_hmQSZay~YQ}TJBaG(;a~zvrdps8fjOl;Hn657k5mN^2FQj4F*OZ1ekEP)| zE)5-af14C$_~p`ynCok<)@vWn^WW$^?~7UidcOI!#YKwbn=D_eH%zBRLKxCRROG&%HZHt=|}Q@;g9oTqSyE^=BWZn_diUZ;z= z=apgjROdzb)3qG6K7_bhFpK+nxS@qh7cyNske?+BTPRL`4i+vHq2lsEkX1lr zpAJO!P5F%4R)y}?Lw9SUqv9H$)cCXPE<|ROncp9*iUnv}Png!L0F4sKlZYM)Sb?6sQK!f{PSyZfC&_w& ztUJAf3hIq2WfG{V!E5nzE-|L$`Q3-XIj(*bEeD#(2 z#`e;&{}NlSX5Kqr5j)F5=c3rT2;1?i2%SIQV|mP0xh!wQyqd9GU~;%DZ!o=g-g1G- zbS(&-<@@rT6FP5-%dhOayss=ouJtl&hQLwdR}mtY_E|2AxYTEnv*0jX3pVo{rDpLs z3Gh>ndoS-T3u~`ErCZ|KMZx%0gteFUS}u#AJPeLma2T!yn|Y2>vv`~Y_$fyQMaCAz zbu;3+vJjhog;_IVY*8?N6(Lp@*I7=Bpxg|WSs`Y+7C-`qq<}d`M9|Z(So}@`Ayzqh z7R7d8x@Dnf8b~Ot8L?+kFn$%Gr%ZUS?W5C_v*9-@^q8&%kSGTQ%sC>0o(2M1n}*J1 z_4$Tt&rPqmwx=v?xV8t-?4n@&D#8ZpciTg!DNn<17956a!DgPLge)E>0e-;ESG(q^ z)}7Nkm^I@NNDKj1YZ@2x4jLjly~7e{CxH+fF*VRHPQdW|6Ct?AkhF#1HV}df>%)V$ z=yOA5F1tM7WyV5qUt-h6*F|v0w+~VcYs~oJde^p!9?3PHj(eE~ciz(Ae#JDnB~VhS zA?||)7djJnXmD-^eraK!c2R)nSx9LWW?Y}wLJ%d-lIdDkswD4{ycRN6gG~Af*Q|qf z(RDgz94Xhw+#ZB>xq*0LcU<&9L;OK4=8hh#b4{!$#5ikj{Ibb`5qlp{sEq|YB>&{Z>HBkh#PP`kkcW$d=$O|Ou4PncyahGXAorSjnLxslXnot z`8iIwbuSQx7xeg;t%g?XjdYxDD9#UvS*eqZ$dtbtt#CaR z*f_*~gE(_!St^;FC+GLcD#CIqf4gP6T+9?YSqAlhv*!&CWeu-)@XP{FMv9ZEWS*Xp z7!NQM`8-lMMyUu`CZMzVU4cV}&em|B&W2SgI#2h4cP@%g&4^G@Ktypu4dQ)h4X|cJ z9FZEoim67v_$HeLrK&%mCk92)=KHV zwGx_&wN@feqOl?}4aT6SCFgQ!(a;3#b?O^fH%V!?)=h!mb(@3rLE;vDZZtEOT^{f< zLj#7b;~#=3>2LG6e!j@JFL_znzFxYQPw*4233tM}CEiZgrRfn&jj=B$OD^*?w4KA* zC;rU$dE{QtMYzperx82jqWL%59q#1JU_F^rk<2C3HFT zZl>J6bI#v)&&>s&MhJehf#{d);{31L4tYKQ<`oaM1*#qlkY4g9(!7R|v_j_%bapQA zCwUT@ll+HalA)9Qjl;V8ekr1!W@YL?Pc3~_K86!^iSNckt3HYagY$O2iF?^K;fiq+ zuA?}c9`BIcPMk~1KWJ%hdMU}SdkSxz*4MvsfBz}EDcQy;ydA&p$@u;8ae5q( zg7#>tfBJ8q9!uhqyq4})b9(7@T(z^ySx47`UTW!t4o#u~+S(DRVLke_9sfmFmWzhi zvz)00jM%P-0axLHrYDsiE8w12mY(k@njixg(1Zz~0h$jt=u|xTV?)904H6z2Vt2Y= zbzovBaW8R4QAm1&p})Ymz?D4h+)1cvBpUP(x7UqH!zJ%UlmYgH1sS_yJ@QZBQ8mm1 z`!eY~E{@3uv|cvib+98*l<@#_1PkEExulvH!(&#cJW6bzemm_z%!-zz6zQJnXDC>? z@x;HJ`unzWc-{1wg>cuePyIm=5cJOSW+(>^<)jYoWuk>nkkFmXPwK$nb4Z2W?Xj1G zOk?;NeB|fJ`bsb<>eG=TzgY#1AJanA+6kkv6$TA4#7G`PdJ3%;2Ard#I2}tjj0|NP zuY?gBV+;*}yJ=V6y8PCAnfIlST5qf^?;5=HRwevmssFc|w^y1^Uf(sH{HXPxCvO~` z={xc6(A5{OPX2P}>d^IFrBmhRlOIznRvJ%~g%dyDjwY>$L@tOY7KN%`g~BbrS=+TB zoIo@^WDfLwF%k5K&)r0n1DC8GgDBw-xEg7g=Ja5+##@z}{JEfs`UE30kpak`VpPE#@{(3!Kho zaEZ~nNa-8RUSKY^PEi{(*o^66GA%-^?Q7p@`S|G9@yFr29Ml?Qm8$JByOQ|dQ%lW$;$XiXE z%m350@2sux%kH}H&g_nxb1lC`)E9odwfbTuZee}#G&b^Itc6x`uaTA&ww(MQ&JD>C literal 0 HcmV?d00001 diff --git a/crates/dspy-py/tests/python/test_adapter.py b/crates/dspy-py/tests/python/test_adapter.py new file mode 100644 index 0000000..387f59a --- /dev/null +++ b/crates/dspy-py/tests/python/test_adapter.py @@ -0,0 +1,97 @@ +"""End-to-end tests: DSRSBAMLAdapter render + parse against a real DSPy install. + +No network and no LM calls — only formatting and parsing. +""" + +import dspy +import pytest +from pydantic import BaseModel, Field + +from dsrs_dspy import DSRSBAMLAdapter + + +class Address(BaseModel): + street: str + city: str = "Unknown" + + +class Patient(BaseModel): + name: str + age: int | None + address: Address + tags: list[str] = Field(default_factory=list) + + +class Extract(dspy.Signature): + """Extract patient information from a clinical note.""" + + note: str = dspy.InputField(desc="Clinical note") + patient: Patient = dspy.OutputField(desc="Extracted patient object") + confidence: float = dspy.OutputField() + + +@pytest.fixture() +def adapter() -> DSRSBAMLAdapter: + return DSRSBAMLAdapter() + + +def test_render_field_structure(adapter: DSRSBAMLAdapter) -> None: + rendered = adapter.format_field_structure(Extract) + + assert "[[ ## note ## ]]" in rendered + assert "[[ ## patient ## ]]" in rendered + assert "[[ ## confidence ## ]]" in rendered + # Nested model fields appear in the BAML schema block. + assert "street" in rendered + assert "city" in rendered + assert rendered.rstrip().endswith("[[ ## completed ## ]]") + + +def test_parse_tolerates_llm_noise(adapter: DSRSBAMLAdapter) -> None: + # Python literals, trailing comma, and a missing space in the marker. + completion = ( + "[[ ## patient ##]]\n" + "{'name': 'Ada', 'age': None," + " 'address': {'street': 'Main St', 'city': 'Springfield'}, 'tags': ['a', 'b'],}\n\n" + "[[ ## confidence ## ]]\n0.95\n\n[[ ## completed ## ]]\n" + ) + + parsed = adapter.parse(Extract, completion) + + patient = parsed["patient"] + assert isinstance(patient, Patient) + assert patient.name == "Ada" + assert patient.age is None + assert patient.address.street == "Main St" + assert patient.address.city == "Springfield" + assert patient.tags == ["a", "b"] + assert parsed["confidence"] == 0.95 + + +def test_parse_restores_pydantic_defaults(adapter: DSRSBAMLAdapter) -> None: + # BAML renders defaultable fields as nullable; a null there means + # "use the pydantic default", not a literal None. + completion = ( + "[[ ## patient ## ]]\n" + '{"name": "Ada", "age": 3, "address": {"street": "Main", "city": null}, "tags": null}\n\n' + "[[ ## confidence ## ]]\n1.0\n\n[[ ## completed ## ]]\n" + ) + + parsed = adapter.parse(Extract, completion) + + assert parsed["patient"].address.city == "Unknown" + assert parsed["patient"].tags == [] + + +def test_parse_missing_field_raises(adapter: DSRSBAMLAdapter) -> None: + from dspy.utils.exceptions import AdapterParseError + + with pytest.raises(AdapterParseError): + adapter.parse(Extract, "[[ ## completed ## ]]") + + +def test_format_produces_chat_messages(adapter: DSRSBAMLAdapter) -> None: + messages = adapter.format(Extract, demos=[], inputs={"note": "Ada, Main St"}) + + user_contents = [m["content"] for m in messages if m["role"] == "user"] + assert any("[[ ## note ## ]]" in content for content in user_contents) From 9dfd0a490fe24cb7dc85e8ec98fcf26f364f4288 Mon Sep 17 00:00:00 2001 From: darin Date: Sun, 26 Jul 2026 14:37:41 -0700 Subject: [PATCH 5/7] ci,docs: build+test dsrs-dspy against DSPy; exclude dspy-py from miri miri cannot interpret an embedded CPython, so the PyO3 crate is excluded there; it is covered by the build, test, clippy, and new python-adapter jobs instead. Co-Authored-By: Claude Fable 5 --- .github/workflows/qol.yaml | 18 +++++++++++++++++- README.md | 20 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qol.yaml b/.github/workflows/qol.yaml index 5ada22d..4f387e4 100644 --- a/.github/workflows/qol.yaml +++ b/.github/workflows/qol.yaml @@ -35,7 +35,23 @@ jobs: toolchain: nightly-2026-01-25 components: miri - run: cargo miri setup - - run: cargo miri test --all-features + # dspy-py is a PyO3 extension; its tests embed CPython, which miri cannot interpret. + - run: cargo miri test --all-features --workspace --exclude dspy-py + + python-adapter: + name: Python Adapter (dsrs-dspy) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: 1.90.0 + - uses: astral-sh/setup-uv@v5 + - name: Build wheel and run adapter tests against DSPy + run: | + uv venv .venv + VIRTUAL_ENV=$PWD/.venv uv pip install ./crates/dspy-py dspy pytest + .venv/bin/pytest crates/dspy-py/tests/python/ -q clippy: name: Clippy Lint diff --git a/README.md b/README.md index 47cfd12..101fe81 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,26 @@ println!("Sentiment: {}", analysis.sentiment); println!("Summary: {}", summary.summary); ``` +## 🐍 Using DSRs from Python (DSPy) + +DSRs's BAML-style schema rendering and JSONish parsing are available to +[DSPy](https://github.com/stanfordnlp/dspy) as a drop-in adapter, via the +`dsrs-dspy` PyO3 package in [`crates/dspy-py`](crates/dspy-py): + +```bash +uv add "dsrs-dspy @ git+https://github.com/krypticmouse/DSRs@main#subdirectory=crates/dspy-py" +``` + +```python +import dspy +from dsrs_dspy import DSRSBAMLAdapter + +dspy.configure(lm=dspy.LM("openai/gpt-5.2"), adapter=DSRSBAMLAdapter()) +``` + +Building from git requires a Rust toolchain. See +[`crates/dspy-py/README.md`](crates/dspy-py/README.md) for details. + ## 🧪 Testing Run the test suite: From a1862b9785b1dcb5edb3a709e28a6530dd548257 Mon Sep 17 00:00:00 2001 From: darin Date: Sun, 26 Jul 2026 15:12:43 -0700 Subject: [PATCH 6/7] ci: cut wall time and dead jobs - Drop the Build job: cargo test --all-features builds a strict superset. - Drop the MIRI job: red on main since at least February (fastant's cpuid ctor aborts every dspy-rs test binary before anything runs), so it has produced no signal for months while costing ~30 minutes per push. Restore it scoped to specific UB-relevant crates if wanted. - Stop double-running: push triggers only on main, PRs use the pull_request event, and in-flight runs for the same ref are cancelled. - Add Swatinem/rust-cache to the remaining jobs; the Test job was ~90% cold compilation. - Clippy now also gates PRs (it previously only ran on push events) and every job gets a timeout. Co-Authored-By: Claude Fable 5 --- .github/workflows/qol.yaml | 42 +++++++++++++------------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/.github/workflows/qol.yaml b/.github/workflows/qol.yaml index 4f387e4..d88e4d7 100644 --- a/.github/workflows/qol.yaml +++ b/.github/workflows/qol.yaml @@ -1,51 +1,37 @@ name: Lint and Test -on: [push, pull_request] +on: + push: + branches: [main] + pull_request: -jobs: - build: - name: Build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - toolchain: 1.90.0 - - run: cargo build +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: test: name: Test runs-on: ubuntu-latest + timeout-minutes: 25 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: toolchain: 1.90.0 + - uses: Swatinem/rust-cache@v2 - run: cargo test --all-features - - miri-test: - name: MIRI Test - runs-on: ubuntu-latest - env: - MIRIFLAGS: -Zmiri-disable-isolation - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly - with: - toolchain: nightly-2026-01-25 - components: miri - - run: cargo miri setup - # dspy-py is a PyO3 extension; its tests embed CPython, which miri cannot interpret. - - run: cargo miri test --all-features --workspace --exclude dspy-py python-adapter: name: Python Adapter (dsrs-dspy) runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: toolchain: 1.90.0 + - uses: Swatinem/rust-cache@v2 - uses: astral-sh/setup-uv@v5 - name: Build wheel and run adapter tests against DSPy run: | @@ -56,13 +42,13 @@ jobs: clippy: name: Clippy Lint runs-on: ubuntu-latest - if: github.event_name != 'pull_request' - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: toolchain: 1.90.0 components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 - run: cargo fmt --check - run: cargo clippy --all-targets --all-features From 3986ba21f1d922ef9b945506eefffcd62e527b08 Mon Sep 17 00:00:00 2001 From: darin Date: Sun, 26 Jul 2026 15:20:55 -0700 Subject: [PATCH 7/7] test(dspy-py): keep property generator out of ambiguous JSONish unions CI's randomized run found the flake: a generated Literal["0"] next to an int in the same union. JSONish deliberately coerces quoted numbers, so for '"0"' against Literal["0"] | int either parse is defensible and a round-trip oracle cannot call a winner. Generate only unambiguous literal strings and enum values (leading letter, no JSONish keywords); the union-scoring behavior itself is unchanged. Verified with ~86k randomized cases across 6 runs. Co-Authored-By: Claude Fable 5 --- crates/dspy-py/src/property_tests.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/dspy-py/src/property_tests.rs b/crates/dspy-py/src/property_tests.rs index c4af4c0..0528625 100644 --- a/crates/dspy-py/src/property_tests.rs +++ b/crates/dspy-py/src/property_tests.rs @@ -70,13 +70,30 @@ fn leaf_type_strategy() -> BoxedStrategy { Just(FuzzType::Int), Just(FuzzType::Float), Just(FuzzType::Bool), - string_regex("[a-zA-Z0-9_-]{1,20}") + // Literal strings must not look like other JSONish scalars: a literal + // "0" next to an int in the same union is genuinely ambiguous (JSONish + // deliberately coerces quoted numbers), so either parse would be + // defensible and the round-trip oracle can't call a winner. + string_regex("[a-zA-Z][a-zA-Z0-9_-]{0,19}") .expect("valid literal string regex") + .prop_filter("literal must not collide with JSONish keywords", |s| { + !matches!( + s.to_ascii_lowercase().as_str(), + "true" | "false" | "null" | "none" | "nan" | "inf" | "infinity" + ) + }) .prop_map(FuzzType::LiteralStr), any::().prop_map(|n| FuzzType::LiteralInt(n as i64)), any::().prop_map(FuzzType::LiteralBool), prop::collection::vec( - string_regex("[a-z]{1,10}").expect("valid enum value regex"), + string_regex("[a-z]{1,10}") + .expect("valid enum value regex") + .prop_filter("enum value must not collide with JSONish keywords", |s| { + !matches!( + s.as_str(), + "true" | "false" | "null" | "none" | "nan" | "inf" | "infinity" + ) + }), 2..6, ) .prop_map(|values| FuzzType::Enum(dedupe_non_empty_strings(values))),