Draft RFC: an exportable `forward` subset — portable DSPy program artifacts (early draft, feedback wanted)
> [!NOTE]
> **Draft RFC** — this is an early design draft posted for visibility and initial feedback; details (especially the v0.3 tables) are expected to change. Not a finalized proposal.
## Summary
This is an RFC on a design we have been building and validating for a while: making a DSPy program **exportable as a single portable artifact** — signatures, instructions, demos, adapter configuration, tools, and the module's `forward` control flow — that can be loaded and executed identically on another machine, and in principle by runtimes in other languages (working grade-1 readers exist in Python, Go, and TypeScript against a shared conformance corpus).
For that to work, `forward` has to stop being "whatever Python does" and become **data**: a closed, versioned tree whose every construct has written-down semantics and conformance fixtures. This issue explains the selection rule we used, the evidence, what the subset currently contains, and the next proposed batch — posted here so the selection can be discussed in the open and community feedback can shape it before anything freezes.
## The three-way rule
Every Python construct lands in exactly one bucket:
1. **Admit** — it becomes a node in the exported tree, with semantics pinned in ~5 lines that every language can implement identically (`if`, `for`, `while`, `try/except/raise`, assignment, f-strings, list/dict literals, indexing, comparisons, arithmetic).
2. **Desugar** — convenience syntax over admitted semantics; the exporter rewrites it at export time (comprehensions → loops; `a, b = pair` → destructuring; `x += 1` → `x = x + 1`; `is None` → `== None`). You write natural Python; the artifact stays closed.
3. **Refuse, loudly, by name** — the exporter names the construct, the location, and the reason. Never a silent partial export.
The admission test is strict on purpose: a construct is admitted only if its semantics are small enough to pin exactly *and* implementable identically across languages. Two examples of the discipline cutting both ways:
- `%` (mod) is **refused**: sign semantics differ across Python/Go/JS — refusing beats specifying it wrongly for someone.
- Dict **insertion ordering is pinned** (observable, duplicate keys keep original position), which costs Go an ordered-map value type. We paid it because real code depends on it.
Similar hard pins: exact 64-bit ints (overflow raises a typed error, never wraps; JS runtimes must carry BigInt), no NaN/Inf in the value model (arithmetic that would produce them raises a catchable typed error), strings as well-formed Unicode scalar sequences, containers as reference values with defined aliasing.
## Chosen by census, not taste
Two censuses drove every admission:
1. **Every shipped DSPy predict module** — Predict, ChainOfThought, ReAct (v1/v2), ProgramOfThought, CodeAct, RLM, Avatar, MultiChainComparison, KNN, Parallel, aggregation — every AST node and call target in `forward` + helpers.
2. **389 community-written `Module.forward` methods** from 288 files across ~75 public GitHub repos that depend on DSPy (found via code search for `dspy.Module` + `def forward` and variants).
Findings that shaped the subset:
- **46% of community forwards** (177/389) need nothing beyond plain assignments, calls, ifs, and loops.
- The rest overwhelmingly need the same short list: f-strings (737 occurrences), subscripts (610), list/dict literals (339), tuples/destructuring (199), comprehensions (102), boolean logic, slicing (`passages[:k]`, 42), and light value methods — `.strip()` (100), `.append()` (90), `.get()` (80), `.join()` (60), `.split()` (39).
- Only **~10% of forwards** touch anything in the refused families, and mostly incidentally (a lambda sort key, an import inside forward).
## Current state of the subset (v0.1 + v0.2)
Statements: `Assign`, tuple destructuring, subscript writes (`trajectory[key] = v`), `Return`, `If`, `For` (ranges and lists), `While` (+ iteration cap so a loaded artifact cannot spin a host forever), `Break`/`Continue`, `Try`/`ExceptHandler`/`Raise` over a typed error table.
Expressions: calls (see leaf rule below), attribute reads on records, f-string formatting, list/dict literals, indexing, `and/or/not`, conditional expressions, comparisons incl. orderings (mathematically exact across int/float; code-point string ordering) and membership, `+ - * /` arithmetic.
**The leaf rule** is the heart of it: every call must resolve to something *declared* — a Predict, a sub-module, a tool, or a code interpreter. A leaf's *body* is full unrestricted Python traveling as introspectable source with declared deps. Restriction is for orchestration; freedom lives at the leaves. Heavy lifting (numerics, regex surgery, pandas, an ODE solver) is a tool leaf with a typed contract, not a reason to grow the tree grammar.
## The next batch (v0.3, proposed — this is the discussion item)
Evidence-backed by the censuses above, currently proposed:
- **`Slice` reads** — `xs[:k]`, `text[:2000]`, `h[-5:]` (Python clamping semantics; literal step ±1; no slice assignment).
- **A ~12-name builtin table** — `len`, `str`, `int`, `float`, `max`/`min`, `sum`, `sorted` (no lambda keys), `any`/`all`, `json_dumps`/`json_parse` (bound to the artifact's canonical JSON serde). All pure functions of their operands.
- **A ~18-name value-method table** — str `strip/lower/upper/split/join/replace/startswith/endswith`; list `append/extend/pop`; dict `get/keys/values/items/pop/update` (which also unlocks `d1 | d2` as a desugar).
- **`print(...)` compiles to a `log` statement** targeting the run's observability trace — never program state. Declared outputs belong in signatures.
- **A pinned float repr** — shortest-round-trip decimal with explicit divergence rules across Python/Go/JS — unblocking `str(float)`, float f-string slots, and `json_dumps` at once.
- **Named refusals**: `*args` splat, the `async` family (scheduling is engine policy — a forward states *what* calls happen; engines batch/parallelize underneath), `import` inside forward (undeclared dependency = undeclared leaf), `lambda`, set literals.
And the standing refusals worth debating openly:
- **`with dspy.context(...)` is not exportable.** Ambient state is precisely what a portable artifact cannot carry — a program whose behavior depends on invisible context cannot make portable claims about its own scores. Explicit per-predictor bindings are the replacement.
- **Runtime class definition / opaque reward callables** (the shipped Refine/BestOfN mechanics) are refused; the *concept* ("try N, keep best") remains expressible as a metric leaf + ordinary loop.
## Versioning promise
Artifacts state the subset version they were written against; runtimes state the set of versions they support; mismatch refuses loudly. Additions only ever go: evidence → proposed pinned semantics → ratification → conformance fixtures → all runtimes implement and pass.
## What we are asking the community
1. Does the admit/desugar/refuse split and the leaf rule match how you write forwards? What would your own code hit first?
2. Anything in the v0.3 tables that looks wrong, missing, or over-included? (Every row carries census counts; happy to share the methodology.)
3. Constructs you write *constantly* that we should census next — real repos/snippets are exactly the evidence that moves the subset.
4. Opinions on the deliberate refusals (`dspy.context`, async, lambda) and their migration paths.
Nothing here is frozen upstream — this is design discussion with a working implementation behind it, posted for feedback and a public trace.
0 条评论