{"id":1501,"date":"2026-07-12T19:20:23","date_gmt":"2026-07-12T23:20:23","guid":{"rendered":"https:\/\/yuguangzhang.com\/blog\/?p=1501"},"modified":"2026-09-10T18:57:21","modified_gmt":"2026-09-10T22:57:21","slug":"how-i-built-an-orchestrated-nl2sql-pipeline-that-scales-at-test-time","status":"publish","type":"post","link":"http:\/\/yuguangzhang.com\/blog\/how-i-built-an-orchestrated-nl2sql-pipeline-that-scales-at-test-time\/","title":{"rendered":"How I Built an Orchestrated NL2SQL Pipeline That Scales at Test Time"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Natural language to SQL is one of those widely applicable problems that has not yet been solved with AI. On BIRD, the hardest public text-to-SQL benchmark, state-of-the-art single-pass systems top out around 75% execution accuracy. Human experts score 92.96%. The gap sits almost entirely on complex, multi-join, domain-rich questions where schema ambiguity and missing literals trip up every model.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This post walks through <code>scale-analytics<\/code>, a pipeline I built that reaches 89.67% execution accuracy on the test set by combining three axes of compute scaling \u2014 parallel synthesis, iterative refinement, and tournament selection \u2014 into a single orchestrated system. The goal was a production-grade design: observable, testable, model-agnostic, and runnable without proprietary RL-trained checkpoints.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The Core Insight: Scaling Compute, Not Parameters<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is a restatement of <a href=\"https:\/\/en.wikipedia.org\/wiki\/Bitter_lesson\">Sutton&#8217;s Bitter Lesson<\/a> applied to inference: given a fixed model budget, the best lever is <em>more compute at query time<\/em>, not a bigger model or better prompts. Three scaling axes compound:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Parallel (width)<\/strong>: generate many SQL candidates with diverse strategies and temperatures.<\/li>\n\n\n\n<li><strong>Sequential (depth)<\/strong>: iteratively refine each candidate \u2014 fix syntax errors, then semantically revise.<\/li>\n\n\n\n<li><strong>Internal (quality)<\/strong>: use RL-trained models that reason longer per token rather than stopping at the first plausible answer.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The architecture translates these three axes into four layers.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Architecture Overview: L0 to L3<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>\nL0 (offline, per-database)\n  |\n  +--> Light Schema  (markdown table + column descriptions)\n  +--> DDL Schema    (CREATE TABLE for code-specialised models)\n  +--> Cell Vector Store  (Chroma, all-MiniLM-L6-v2, one per DB)\n  +--> Example Vector Store  (Chroma, question skeleton -> sql)\n  +--> BM25 Index    (lexical fallback)\n\nL1 (online, per query) -- Task Understanding\n  KeywordSkeletonExtractor --> CellRetriever || ExampleRetriever\n\nL2 (online) -- SQL Generation Scaling\n  ReasoningGenerator || ICLGenerator x 3 strategies\n  --> ExecutionBucketer --> SQLFixer (syntax) --> CritiqueAgent --> SQLRevisor (semantic)\n  * critique-conditioned re-synthesis (up to 2 passes)\n\nL3 (online) -- SQL Selection Scaling\n  ExecutionBucketer --> TournamentArbiter --> ReasoningSelector\n  --> Final SQL\n\n\n<\/code><\/pre>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"554\" src=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/overall-architecture-1024x554.png\" alt=\"\" class=\"wp-image-1508\" srcset=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/overall-architecture-1024x554.png 1024w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/overall-architecture-300x162.png 300w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/overall-architecture-768x416.png 768w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/overall-architecture.png 1184w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"580\" src=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l0-offline-prep-1024x580.png\" alt=\"\" class=\"wp-image-1509\" srcset=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l0-offline-prep-1024x580.png 1024w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l0-offline-prep-300x170.png 300w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l0-offline-prep-768x435.png 768w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l0-offline-prep.png 1117w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">L0 runs once when database fields are updated. L1\u2013L3 run on every incoming query. The total LLM call count per query is around fifteen in the default configuration \u2014 expensive, but the pipeline is designed to scale <em>down<\/em> for simple questions via a difficulty router that reduces <code>k_icl<\/code> and disables the tournament when the question is straightforward.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">L1: Task Understanding<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The first online stage extracts two things from the incoming question: <strong>database literals<\/strong> (exact cell values the SQL will need to match verbatim) and a <strong>question skeleton<\/strong> (a structural abstraction used to find similar few-shot examples).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">These run in parallel via LangGraph&#8217;s <code>Send<\/code> primitive:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>KeywordSkeletonExtractor (LLM, Gemini Flash T=0.2)\n         |\n         +--> literals --> CellRetriever (k=5, cosine threshold 0.8)\n         `--> skeleton --> ExampleRetriever (k=15, threshold 1.5)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Cell retrieval searches a per-DB Chroma collection built from TEXT columns in the source database, filtered to exclude primary keys, IDs, emails, and strings over 256 characters. Example retrieval searches a global collection of training-set question skeletons to find structurally similar past questions with known-correct SQL.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both retrievers return typed Pydantic objects (<code>CellHit<\/code>, <code>ExampleHit<\/code>) that the downstream generators consume directly \u2014 no string concatenation, no ad-hoc dicts.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"1014\" height=\"783\" src=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l1-task-understanding.png\" alt=\"\" class=\"wp-image-1510\" srcset=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l1-task-understanding.png 1014w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l1-task-understanding-300x232.png 300w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l1-task-understanding-768x593.png 768w\" sizes=\"auto, (max-width: 1014px) 100vw, 1014px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">L2: SQL Generation Scaling<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is where the diversity-then-refinement strategy plays out.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Diverse Synthesis<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Two generator families run in parallel, each with a different schema view and a different prompting approach:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>ReasoningGenerator<\/strong> \u2014 sees the DDL schema (<code>CREATE TABLE<\/code> with inline comments), no examples. Uses chain-of-thought inside <code>&lt;think&gt;\u2026&lt;\/think&gt;<\/code> tags, then emits SQL inside <code>&lt;sql&gt;\u2026&lt;\/sql&gt;<\/code>. Designed to slot in RL-trained reasoning checkpoints when available; falls back to DeepSeek-Coder-V2 or GPT-5 at high effort today.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>ICLGenerator<\/strong> \u2014 sees the light markdown schema plus retrieved few-shot examples. Runs three sub-prompts in parallel to maximise diversity:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Direct<\/strong>: few-shot SQL completion.<\/li>\n\n\n\n<li><strong>Chain-of-Thought<\/strong>: reason in natural language, then emit SQL.<\/li>\n\n\n\n<li><strong>Question Decomposition<\/strong>: split into sub-questions, solve bottom-up, compose the final SQL.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Each of these generators outputs the commented SQL along with the reasoning in bullet points. Default configuration: 2 reasoning candidates + 2 \u00d7 3 ICL candidates = 8 total. Every candidate is executed immediately in the sandbox and tagged with its <code>ExecutionResult<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Iterative Refinement<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The refinement loop composes three agentic patterns so it <em>learns within a single query<\/em> from every generator&#8217;s reasoning, rather than treating each candidate as an independent draft:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Reflection<\/strong> \u2014 a dedicated <code>CritiqueAgent<\/code> separates generation from evaluation; structured feedback drives the next edit.<\/li>\n\n\n\n<li><strong>Memory Management<\/strong> \u2014 a task-scoped <code>ReasoningTrace<\/code> acts as an episodic scratchpad, recording every generator&#8217;s rationale, execution result, and critique for the duration of one query.<\/li>\n\n\n\n<li><strong>Learning and Adaptation<\/strong> \u2014 when the critique signals a framing problem that revision alone can&#8217;t fix, the orchestrator re-invokes the generators with accumulated lessons injected into their prompts.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The four components of the loop, in order:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>ExecutionBucketer<\/strong> (no LLM) groups all candidates by <code>ExecutionResult.hash<\/code>. Candidates with errors or zero rows are flagged suspicious. Bucket metadata \u2014 size, producer mix, mean rationale length \u2014 is written to <code>ReasoningTrace<\/code> so downstream agents know which generators agreed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>SQLFixer<\/strong> (conditional) repairs candidates that errored. It reads <code>ReasoningTrace.recent_fixes(producer)<\/code> to avoid repeating a repair strategy that already failed on a sibling candidate from the same generator. Bounded at 2 passes; failed candidates are dropped from the tournament but kept in the trace.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>CritiqueAgent<\/strong> (new) runs once per non-error bucket, <em>before<\/em> the revisor. It reads the bucket representative, its reasoning trace, and the rationales of every other generator \u2014 so it can contrast hypotheses, not just evaluate one candidate in isolation. It emits a typed <code>Critique<\/code> with:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>error_class<\/code> \u2014 a closed enum (<code>wrong_join<\/code>, <code>missing_filter<\/code>, <code>wrong_aggregation<\/code>, <code>value_mismatch<\/code>, <code>evidence_ignored<\/code>, <code>other<\/code>) so critiques are aggregatable across buckets and queries.<\/li>\n\n\n\n<li><code>complexity<\/code> \u2014 an integer 1\u201310 rating of how convoluted the reasoning required to answer the question is, based on the spread of generator rationales and the bucket representative&#8217;s structure. Anything ? 7 means editing alone is unlikely to converge.<\/li>\n\n\n\n<li><code>suggested_fix<\/code> \u2014 an actionable rewrite hint for the revisor.<\/li>\n\n\n\n<li><code>generator_feedback<\/code> \u2014 corrective material (<code>correction<\/code> or <code>warning<\/code>) for the generators on a re-synthesis pass, distinct from <code>suggested_fix<\/code> because it needs to be generic enough to seed multiple new candidates from different prompting strategies.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>retry<\/code> verdict is <strong>derived by the orchestrator<\/strong>, not chosen by the LLM: if <code>complexity &gt;= 7<\/code>, the orchestrator forces <code>verdict = \"retry\"<\/code> regardless of what the model wrote. This keeps the threshold a single source of truth in code and makes it unambiguously trace-grounded.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>SQLRevisor<\/strong> runs on bucket representatives when the verdict is <code>edit<\/code>. It now receives the <code>Critique<\/code> (what is wrong) and a cross-bucket summary from <code>ReasoningTrace<\/code> (what other generators answered instead), so it is no longer reasoning blind about a single candidate. Bounded at 1\u20132 passes; any new SQL re-enters the loop from execution onward.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Critique-Conditioned Re-Synthesis<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">After each revisor pass, the orchestrator checks whether re-synthesis is warranted. It triggers a new generation round when either:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Any bucket&#8217;s critique has <code>complexity &gt;= 7<\/code> (a <code>retry<\/code> verdict) \u2014 high-complexity questions are where framing errors happen at synthesis time, not editing time.<\/li>\n\n\n\n<li>No bucket was accepted <em>and<\/em> at least one <code>error_class<\/code> is shared across two or more buckets \u2014 meaning the generators systematically misread the same aspect of the question.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">When triggered, the generators are re-invoked with a <code>LessonsBlock<\/code> injected into their prompts, assembled from <code>ReasoningTrace<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Avoid: wrong_join \u2014 the join through order_items produces duplicate rows.\nPrefer: join through orders directly.\n\n## Corrections\n- The join must go through orders, not order_items. &#91;all generators]\n\n## Warnings\n- Be careful with NULL handling on customer.region. &#91;icl\/direct only]\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">New candidates re-enter the full loop (execution ? bucketing ? critique ? revision). Re-synthesis is capped at <strong>2 passes<\/strong> (<code>resynth_max_passes<\/code>) and generates <code>k_resynth = max(1, k_icl \/\/ 2)<\/code> candidates per generator per pass. After 2 passes, L3 runs on whatever pool exists \u2014 the cap is non-negotiable to prevent reflection-loop runaway.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>ReasoningTrace<\/code> itself is task-scoped and ephemeral: it lives for the duration of one query and is serialised into the final <code>TaskContext<\/code> for L3 and post-hoc analysis, but it is not long-term memory. Recurring lessons (distilled <code>(skeleton, error_class, fix)<\/code> triples) can optionally be written to a cross-task lesson store that augments L1 example retrieval on future queries \u2014 but that path is offline and does not affect the serving pipeline&#8217;s statefulness invariant.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"663\" height=\"1024\" src=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l2-generation-scaling-663x1024.png\" alt=\"\" class=\"wp-image-1511\" srcset=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l2-generation-scaling-663x1024.png 663w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l2-generation-scaling-194x300.png 194w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l2-generation-scaling-768x1186.png 768w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l2-generation-scaling-995x1536.png 995w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l2-generation-scaling.png 1184w\" sizes=\"auto, (max-width: 663px) 100vw, 663px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">L3: SQL Selection Scaling<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After refinement, you have a pool of candidates grouped by execution result hash. The tournament works like this:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Take one representative per non-error bucket ? set <code>C'<\/code>.<\/li>\n\n\n\n<li>If <code>|C'| == 1<\/code>, return it immediately.<\/li>\n\n\n\n<li>Otherwise run pairwise round-robin: for every unordered pair <code>(c_i, c_j)<\/code>, ask the <code>ReasoningSelector<\/code> which SQL better answers the question. It sees the question, the light schema, both SQL strings, and both execution previews.<\/li>\n\n\n\n<li>Rank by win count. Tie-break: (a) higher win count, (b) larger bucket (more generators agreed), (c) prefer the reasoning generator (empirically higher precision on simple\/moderate questions).<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Complexity is <code>O(|C'|\u00b2)<\/code> selector calls. In practice <code>|C'|<\/code> is 2\u20135 after bucketing, so the tournament costs 1\u201310 calls.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"368\" height=\"1024\" src=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l3-selection-scaling-368x1024.png\" alt=\"\" class=\"wp-image-1512\" srcset=\"http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l3-selection-scaling-368x1024.png 368w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l3-selection-scaling-108x300.png 108w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l3-selection-scaling-552x1536.png 552w, http:\/\/yuguangzhang.com\/blog\/wp-content\/uploads\/2026\/07\/l3-selection-scaling.png 605w\" sizes=\"auto, (max-width: 368px) 100vw, 368px\" \/><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The Orchestrator: LangGraph as the Graph Runtime<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The orchestrator is the only place where scaling lives as code. Agents are stateless; they have no idea about retries, fan-out width, or loop depth. The orchestrator&#8217;s <code>ScalingConfig<\/code> owns all of that:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class ScalingConfig(Frozen):\n    k_reasoning: int = 2\n    k_icl: int = 2                     # per sub-prompt\n    k_resynth: int = 1                 # candidates per generator on re-synthesis (default max(1, k_icl\/\/2))\n    fixer_max_passes: int = 2\n    refinement_max_passes: int = 1\n    resynth_max_passes: int = 2        # critique-conditioned re-synthesis depth\n    complexity_retry_threshold: int = 7  # Critique.complexity at or above which verdict is forced to \"retry\"\n    tournament_enabled: bool = True\n    global_timeout_s: int = 120\n    token_budget: int = 200_000\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">I chose LangGraph over CrewAI, smolagents, Google ADK, and plain <code>asyncio<\/code> for one concrete reason: the pipeline&#8217;s structure \u2014 two fan-outs, two bounded loops, one tournament \u2014 maps directly onto LangGraph primitives. The <code>Send<\/code> primitive expresses per-candidate execution, per-bucket revision, and per-pair selector calls as first-class graph nodes rather than hand-rolled <code>asyncio.gather<\/code> blocks. Conditional edges express <code>exec_result.error? ? fixer<\/code> and <code>refinement_passes_left? ? revisor<\/code> without hand-rolled <code>while<\/code> loops. Checkpointers give us replay for free.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What we deliberately <em>don&#8217;t<\/em> take from LangGraph: its model wrappers. Every LLM call goes through our own <code>model-adapter<\/code>, and our <code>AgentEvent<\/code> schema is the authoritative lineage format \u2014 LangSmith and JSONL are sinks, not owners.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Data Contracts: Pydantic Frozen Models<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every type that crosses a module boundary is an immutable Pydantic v2 model. No dicts, no prompt strings, no ad-hoc tuples.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>class Frozen(BaseModel):\n    model_config = ConfigDict(frozen=True, extra=\"forbid\", str_strip_whitespace=True)\n\nclass CandidateSQL(Frozen):\n    sql: str\n    producer: str                          # \"reasoning\/T=0.7\", \"icl\/cot\/T=1.0\"\n    rationale: Optional&#91;str] = None\n    exec_result: Optional&#91;ExecutionResult] = None\n    lineage: list&#91;str] = Field(default_factory=list)  # &#91;\"gen\", \"fix\", \"revise:pass2\"]\n\nclass ExecutionResult(Frozen):\n    rows: tuple&#91;tuple, ...]\n    columns: tuple&#91;str, ...]\n    row_count: int = Field(ge=0)\n    error: Optional&#91;str] = None\n    hash: str\n    elapsed_ms: int = Field(ge=0)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>frozen=True<\/code> makes every contract hashable and safe to share across async tasks. <code>extra=\"forbid\"<\/code> catches typos. <code>ExecutionResult.hash<\/code> is computed over a canonical form of the result set \u2014 sorted rows, normalised timestamps, float rounding to 6 decimal places \u2014 so that two syntactically different SQLs that return the same answer collapse into the same bucket.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The Execution Sandbox<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every candidate SQL is executed in a read-only sandbox before any LLM refinement or selection decision. This is load-bearing: bucketing, revision, and tournament selection all depend on the canonical hash.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Read-only enforcement is multi-layered:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>SQLite: <code>PRAGMA query_only = ON<\/code>, opened with <code>mode=ro<\/code> URI.<\/li>\n\n\n\n<li>A lightweight AST check rejects <code>INSERT | UPDATE | DELETE | DROP | ALTER | ATTACH<\/code> before execution reaches the driver.<\/li>\n\n\n\n<li>Write violations raise <code>ReadOnlyViolation<\/code> synchronously; the connection is discarded, not returned to the pool.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The canonicalisation pipeline:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>raw rows\n  ? sort in-memory (stable tuple comparator; do NOT add ORDER BY to the SQL)\n  ? normalise per cell (rstrip strings, round floats to 6dp, ISO-8601 timestamps, null sentinel)\n  ? BLAKE2b hash over canonical JSON\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Error results get an <code>ERR:<\/code> prefixed hash so they can never hash-collide with a successful result, and identical syntax errors across multiple candidates collapse into the same error bucket.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Observability: Three-Surface Tracing<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every agent call emits a typed <code>AgentEvent<\/code> following OpenTelemetry GenAI semantic conventions. Events are tagged with one of three surfaces:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Surface<\/th><th>What it measures<\/th><th>Example agents<\/th><\/tr><\/thead><tbody><tr><td><code>cognitive<\/code><\/td><td>Plan quality, reasoning correctness<\/td><td>Keyword extractor, generators, selector<\/td><\/tr><tr><td><code>operational<\/code><\/td><td>Tool-call reliability<\/td><td>SQL executor, fixer<\/td><\/tr><tr><td><code>contextual<\/code><\/td><td>Retrieval precision<\/td><td>Cell retriever, example retriever<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The evaluation harness derives per-surface metrics from this stream: <strong>Plan Adherence Rate<\/strong>, <strong>Tool Call Success Rate<\/strong>, <strong>Retrieval Precision<\/strong>, and <strong>Critic Adherence Rate<\/strong> (how often the Revisor actually adopts its own critique).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Revisor&#8217;s reflection loop is a first-class event:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>async with tracer.span(\"l2.sql_revisor\", surface=\"cognitive\", node=\"l2.revise\") as span:\n    verdict = await self._critique(candidate)\n    await tracer.critique(\n        \"l2.sql_revisor\",\n        RevisionDelta(\n            before_hash=candidate.exec_result.hash,\n            after_hash=new_result.hash,\n            verdict=\"revise\",\n            rationale=\"...\",\n        ),\n    )\n    span.set_output(revised_candidate)\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This design lets you compute Critic Adherence Rate offline: for every case where the revisor said &#8220;this needs a change,&#8221; did the new candidate actually produce a different result?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Events fan out to LangSmith (default) and a local JSONL file in parallel. If LangSmith is unreachable, the pipeline continues with the JSONL sink and logs a single warning.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">DSPy for Prompt Compilation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Agents that the pipeline actually cares about optimising \u2014 the keyword extractor, the three ICL generator sub-prompts, the <code>CritiqueAgent<\/code>, the revisor, and the selector \u2014 are expressed as DSPy <code>Signature<\/code>s compiled against execution-grounded metrics.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The Revisor&#8217;s compilation metric is approximately:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def revisor_metric(example, pred, trace=None):\n    result = sandbox.execute(pred.sql, db_id=example.db_id)\n    if result.hash == example.gold_hash:\n        return 1.0\n    elif result.error is None:\n        return 0.1\n    return 0.0\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The metric mirrors the same execution-grounded reward used to train RL-tuned reasoning models. When a stronger checkpoint becomes available, it replaces the compiled prompt behind the same DSPy <code>Signature<\/code> with zero orchestration changes \u2014 the signature is the contract, and either side (compiled prompt or RL model) can fulfill it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Graceful Degradation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The pipeline fails hard in exactly one scenario: every generator produced zero candidates. Otherwise degradation is priority-ordered and monotonic:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><code>KeywordSkeletonExtractor<\/code> fails &#8211;> continue with empty <code>Understanding<\/code>.<\/li>\n\n\n\n<li><code>CellRetriever<\/code> or <code>ExampleRetriever<\/code> fails &#8211;> continue with empty list.<\/li>\n\n\n\n<li>One ICL sub-prompt fails &#8211;> continue with remaining generators.<\/li>\n\n\n\n<li><code>SQLFixer<\/code> \/ <code>SQLRevisor<\/code> fails &#8211;> skip that pass, keep pre-refinement candidates.<\/li>\n\n\n\n<li><code>ReasoningSelector<\/code> fails mid-tournament &#8211;> fall back to majority vote over bucket sizes.<\/li>\n\n\n\n<li>Budget or timeout exceeded &#8211;> return best-so-far candidate tagged <code>degraded=True<\/code>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Every degradation fires a <code>DecisionEvent<\/code> with a label, confidence, and feature dict. The evaluation harness computes <code>degraded_rate<\/code> from the trace \u2014 so you can see whether degraded runs score proportionally worse and by how much.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Evaluation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The evaluation harness runs on a proprietary set of commonly asked questions in the healthcare domain about claims data. Primary metric is execution accuracy \u2014 does the candidate SQL produce the same result set as the gold SQL? Secondary is reward-based valid efficiency score, which weights correct queries by execution-time ratio vs gold.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The ablation table covers: drop the reasoning generator, drop ICL, drop the tournament (? self-consistency), drop refinement. Any fork of this codebase can compare directly.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">What&#8217;s Left<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two RL-trained checkpoints \u2014 a 32B reasoning generator and a 32B selector \u2014 would push accuracy higher but are not yet available. The pipeline runs on zero-shot frontier LLMs today (GPT-5, Gemini 2.5 Pro, Claude as the selector); those slots are designed to accept stronger models as drop-in replacements.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A difficulty router that bumps <code>k_icl<\/code> and <code>refinement_max_passes<\/code> for questions tagged <code>challenging<\/code> is designed but not yet wired. Pass@k gains are largest on the challenging bucket, so targeted scaling pays off more than uniform scaling across easy questions.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">References<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Sutton (2019). <em>The Bitter Lesson.<\/em> incompleteideas.net<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>Natural language to SQL is one of those widely applicable problems that has not yet been solved with AI. On BIRD, the hardest public text-to-SQL benchmark, state-of-the-art single-pass systems top out around 75% execution accuracy. Human experts score 92.96%. The gap sits almost entirely on complex, multi-join, domain-rich questions where schema ambiguity and missing literals [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_import_markdown_pro_load_document_selector":0,"_import_markdown_pro_submit_text_textarea":"","footnotes":""},"categories":[1],"tags":[],"class_list":["post-1501","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"aioseo_notices":[],"_links":{"self":[{"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/posts\/1501","targetHints":{"allow":["GET"]}}],"collection":[{"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/comments?post=1501"}],"version-history":[{"count":7,"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/posts\/1501\/revisions"}],"predecessor-version":[{"id":1520,"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/posts\/1501\/revisions\/1520"}],"wp:attachment":[{"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/media?parent=1501"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/categories?post=1501"},{"taxonomy":"post_tag","embeddable":true,"href":"http:\/\/yuguangzhang.com\/blog\/wp-json\/wp\/v2\/tags?post=1501"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}