ITADN

Draft RFC: an exportable `forward` subset — portable DSPy program artifacts (early draft, feedback wanted)

#10187OpenMaximeRivest 创建于 19 天前
M
MaximeRivestcommented
> [!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 条评论