Skip to content

feat(sql-optim-env): add execution-grounded DuckDB SQL query optimization environment - #1006

Open
OfficialAbhinavSingh wants to merge 15 commits into
openenv:mainfrom
OfficialAbhinavSingh:feat/sql-optim-env
Open

OfficialAbhinavSingh wants to merge 15 commits into
openenv:mainfrom
OfficialAbhinavSingh:feat/sql-optim-env

Conversation

@OfficialAbhinavSingh

@OfficialAbhinavSingh OfficialAbhinavSingh commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds sql_optim_env, an execution-grounded SQL query optimization environment. The agent receives a slow SQL query plus its schema and returns a rewritten query; reward is not a keyword heuristic — the environment executes both the original and the optimized query against a DuckDB database that is built on a temporary file and then reopened read-only, seeded with synthetic data (10k users, 500k orders, 1k products, 1M events) and scores the agent on measured speedup and result-correctness across five anti-pattern tasks (basic anti-patterns → correlated subqueries → wildcard scans → implicit joins → window-function audits).

Fills a gap in the current catalog: there are coding, reasoning, and finance-QA environments, but none that grounds reward in real database execution.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • New environment
  • Refactoring

Alignment Checklist

Before submitting, verify:

  • I have read .claude/docs/PRINCIPLES.md and this PR aligns with our principles (reward is computed inside the environment from real execution; no domain logic leaks to the trainer)
  • I have checked .claude/docs/INVARIANTS.md and no invariants are violated (Gym-like reset/step/state; simulation controls are training-orchestration only, not agent-facing; the client never imports from server/)
  • I have run bash .claude/hooks/lint.sh and tests and addressed all issues

RFC Status

  • Not required (bug fix, docs, minor refactoring)
  • RFC exists: #___
  • RFC needed (will create before merge)

This is an additive community environment (new envs/ directory only), not a change to src/openenv/core/, so per the contribution guide it shouldn't need an RFC. Happy to write a short one if you'd prefer for a new environment.

Test Plan

  • PYTHONPATH=src:envs uv run --frozen --project envs/sql_optim_env pytest tests/envs/test_sql_optim_environment.py — 54 passed (env driven against real DuckDB: reset loads each task, step scores speedup/correctness, state round-trips; model serialization; client parse helpers; executor sandboxing, timeout and session isolation).
  • PYTHONPATH=src:envs uv run pytest tests/ --ignore=… (the root test job's command) — 2602 passed, 117 skipped.
  • tests/envs/test_client_step_result_contract.py — 9 passed. sql_optim_env is registered in that file, and its cases run in the root job rather than skipping, since the client and models import only openenv and pydantic.
  • End-to-end against the built image over HTTP, task task_1_basic_antipatterns: reset loads the query and a rewrite that drops the CAST and the year() wrapper grades at original_rows=26 optimized_rows=26 match=True. Submitting CREATE TEMP TABLE orders AS SELECT ... as a rewrite scores below an honest one, and the next episode still measures 26 rows rather than the shadowed table, which is the session isolation in 9dc28bf1 holding in the image.
  • docker build -f envs/sql_optim_env/server/Dockerfile envs/sql_optim_env with BuildKit — builds clean on ghcr.io/huggingface/openenv-base:latest. uv.lock puts the Dockerfile on its uv sync --frozen branch, confirmed in the build log (duckdb==1.5.5, CPython 3.11.16, 111 packages). /health returns {"status":"healthy"}.
  • python scripts/sync_env_docs.py --check, ruff check, ruff format --check — all clean.

Reviewers can drive it with:

with SQLOptimEnv(base_url="http://localhost:8000").sync() as env:
    obs = env.reset(task_id="task_1_basic_antipatterns").observation
    result = env.step(SQLOptimAction(
        optimized_query="SELECT id FROM orders WHERE customer_id = 5000",
        summary="Dropped SELECT * and the non-sargable CAST.",
    ))
    print(result.reward, result.done)

Claude Code Review

Self-check against the two-tier model: no bugs, uninitialized state, or debug code; reward stays inside the environment (PRINCIPLES.md), and the Gym reset/step/state boundary, agent/infra separation, and client↔server import rule (INVARIANTS.md) are all preserved. No alignment flags.


Context: I built the original for the Meta PyTorch OpenEnv hackathon and adapted it to the current envs/ conventions (models/client/server, connect4_env shape). Follows on from the sync-bootstrap work in #935 / #959.


Note

Medium Risk
Agent-authored SQL is executed with substantial sandboxing, but correctness and resource limits still depend on DuckDB/process isolation for untrusted workloads; changes are additive under envs/ and do not alter core OpenEnv APIs.

Overview
Introduces sql_optim_env, a new OpenEnv package where agents rewrite slow SQL and are scored from real DuckDB runs (measured speedup plus multiset result equality), not keyword-only checks. The server seeds a temp-file database (users/orders/products/events), grades composite rewards (execution, correctness, issue detection, approval, summary, severity), and exposes five escalating tasks via the usual client/models/FastAPI/Docker/openenv.yaml layout.

The DuckDB executor is the main engineering surface: read-only reopen, locked sandbox (enable_external_access=false), per-query deadlines, short-lived cursors so temp DDL cannot poison later episodes, single-statement validation, bounded materialization for large results, and in-engine correctness via EXCEPT ALL. SQLOptimEnv._parse_result mirrors envelope reward/done onto the observation so clients match other envs.

CI skips the heavy suite in the root pytest job and adds test-sql-optim-env (Python 3.11/12, frozen envs/sql_optim_env deps). Docs add the catalog entry, toctree page, and synced sql_optim.md. Tests include a large test_sql_optim_environment.py plus TestSQLOptimClient in the shared step-result contract file.

Reviewed by Cursor Bugbot for commit 4e662a5. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI review requested due to automatic review settings July 23, 2026 14:49
Comment thread envs/sql_optim_env/server/executor.py Resolved
Comment thread envs/sql_optim_env/server/tasks.py Resolved
Comment thread envs/sql_optim_env/server/tasks.py Resolved
Comment thread envs/sql_optim_env/server/executor.py Resolved
Comment thread envs/sql_optim_env/server/tasks.py Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new community environment, sql_optim_env, where agents rewrite slow SQL and are rewarded based on execution-grounded measurements from an in-memory DuckDB (speedup + result correctness), plus auxiliary rubric components (issue detection, approval, summary, severity). The PR follows the standard OpenEnv env layout (models/client/server), ships Docker + openenv.yaml, and wires the environment into the docs catalog.

Changes:

  • Introduces sql_optim_env server runtime (tasks, DuckDB executor, graders, FastAPI app) and client/models for typed transport.
  • Adds pytest coverage for reset/step/state and client parsing helpers.
  • Adds documentation page + environments index/toctree entries for the new environment.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/envs/test_sql_optim_environment.py Adds unit tests for env reset/step/state and client parsing helpers.
envs/sql_optim_env/server/tasks.py Defines five SQL anti-pattern tasks and metadata used by the environment.
envs/sql_optim_env/server/sql_optim_environment.py Implements the server-side Environment reset/step/state logic.
envs/sql_optim_env/server/scoring_models.py Adds internal Reward model returned by the grader.
envs/sql_optim_env/server/graders.py Implements execution-grounded grading and score breakdown.
envs/sql_optim_env/server/executor.py Implements DuckDB-backed query execution, timing, and correctness checks.
envs/sql_optim_env/server/Dockerfile Provides container build/run path for the environment server.
envs/sql_optim_env/server/app.py Creates the FastAPI app via create_app for WebSocket sessions.
envs/sql_optim_env/server/init.py Exposes SQLOptimEnvironment from the server package.
envs/sql_optim_env/README.md Environment README with quick start, schema, reward breakdown, and usage.
envs/sql_optim_env/pyproject.toml Declares the env package, dependencies (incl. duckdb), and server entrypoint.
envs/sql_optim_env/openenv.yaml Registers the environment spec for deployment/runtime.
envs/sql_optim_env/models.py Defines wire Action/Observation/State models.
envs/sql_optim_env/client.py Adds SQLOptimEnv client with payload/parse helpers.
envs/sql_optim_env/init.py Package exports and top-level docs/example.
docs/source/environments/sql_optim.md Adds docs page for the environment.
docs/source/environments.md Adds sql_optim_env to the environments catalog page.
docs/source/_toctree.yml Adds the environment docs page to the docs toctree.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread envs/sql_optim_env/server/executor.py Resolved
Copilot AI review requested due to automatic review settings July 23, 2026 15:30
@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all seven were real. Addressed in 34c1564:

Execution safety / correctness

  • Shared DB mutation — agent rewrites now run through a read-only guard (single SELECT/WITH statement only; DDL/DML and multi-statement inputs are rejected) and inside a transaction that is always rolled back, so a rewrite can never persist a change to the shared database. Added a test that DROP/DELETE/SELECT 1; DROP … are rejected and the tables are untouched.
  • Checksum broken by trailing ; — queries are normalized (trailing semicolons stripped) before they're wrapped in the COUNT(*) FROM (…) checksum subquery, so the >50k-row correctness path works. Test drives a ;-terminated 1M-row query.
  • Duplicate compare() per step — grade() now carries its comparison on Reward.execution; step reuses it instead of executing both queries a second time. Test asserts compare runs exactly once per step.
  • Stale execution on empty rewrite — step assigns reward.execution directly, which is None for an empty/failed rewrite, so a skipped step no longer reports the previous step's comparison. Test covers non-empty → empty.

Task definitions

  • Task 3 filter matched all rows — dropped the session_id LIKE 'sess_%' branch (every session_id starts with sess_). The filter is now event_type LIKE '%purchase%', whose result-preserving equality rewrite (event_type = 'purchase') is ~1.6× faster and passes correctness. Projection/SELECT * are reframed as detect-only issues (they'd change the output).
  • Task 5 unsolvable rewrite — the intended rewrite is now result-preserving (consolidate the PARTITION BY user_id windows); pre-filter and the global RANK are reframed as flag-only. Also fixed non-determinism: the ORDER BY occurred_at windows now break ties on id, so a correct rewrite can actually match. Dropped the SELECT * ground-truth issue (the query has no *).
  • Task 4 mislabeled subqueries — the two scalar subqueries are uncorrelated global constants; relabeled as such (types, description, keywords) — no more "correlated / per group".

Verified: 21 passed (13 existing + 8 new regression tests), ruff + sync_env_docs --check clean, and a live WebSocket round-trip on the reworked Task 3 (result-preserving rewrite, correct=true) plus the empty-rewrite path.

Comment thread envs/sql_optim_env/server/graders.py Resolved
Comment thread envs/sql_optim_env/server/executor.py Resolved

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

envs/sql_optim_env/server/executor.py:230

  • _checksum() executes queries on the shared DuckDB connection without any locking. Even if _run() is serialized, concurrent calls to _checksum() (from another session) can still race with _run()/compare() and corrupt results or raise driver errors.

Wrap these conn.execute(...) calls with the same lock used elsewhere so the singleton connection is always accessed serially.

            try:
                wrapped = sql_template.format(query=query)
                result = self.conn.execute(wrapped).fetchone()
                return result[0], result[1], None
            except Exception:
                continue
        # Final fallback: count only
        try:
            cnt = self.conn.execute(f"SELECT COUNT(*) FROM ({query}) t").fetchone()[0]
            return cnt, None, None

Comment thread envs/sql_optim_env/server/executor.py Resolved
Comment thread envs/sql_optim_env/server/executor.py Resolved
Comment thread envs/sql_optim_env/server/executor.py Resolved
Copilot AI review requested due to automatic review settings July 23, 2026 15:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Addressed the second round (Bugbot) plus @copilot's remaining points in 55432ec:

  • Read-only filter too strict — dropped the keyword blocklist. _is_read_only now does two structural checks (single statement — no mid-query ; — beginning with SELECT/WITH), backed by the always-rolled-back transaction. A single SELECT/WITH statement can't mutate in DuckDB (no data-modifying CTEs), so the blocklist only produced false rejects on legitimate rewrites using REPLACE(...) or literals like 'set'. Test asserts those now pass while DROP / SELECT 1; DROP … / empty are still rejected.
  • Issue detection scanned the rewrite SQL — _suggestions_text now scans only the agent's analysis (summary + structured suggestions), not optimized_query. Otherwise echoing the slow SQL farmed the detection slice without analysis. Test asserts an echo-with-no-analysis scores issue_detection == 0.
  • Test referenced a non-existent task key (@copilot) — test_state_tracks_episode used task_2_join_optimization, which isn't in TASKS (it's task_2_correlated_subqueries), so it silently fell back to the default. Fixed to target task 2 directly.

On @copilot's note about _run() materializing results three times: that's a deliberate median-of-3 timing for stability against noise — happy to make the run count adaptive for the 1M-row tasks if you'd prefer, but I kept the speedup signal stable for now.

Verified: 23 passed (13 original + 10 regression), ruff + sync_env_docs --check clean. Most of @copilot's other comments (empty-rewrite staleness, double compare, checksum ;, singleton DB) were the same issues fixed in 34c1564.

Comment thread envs/sql_optim_env/server/executor.py Resolved
Comment thread envs/sql_optim_env/server/executor.py Resolved
Comment thread envs/sql_optim_env/server/tasks.py Resolved
Comment thread envs/sql_optim_env/server/tasks.py Resolved
Comment thread envs/sql_optim_env/server/graders.py Resolved
Copilot AI review requested due to automatic review settings July 23, 2026 16:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Round 3 addressed in feeb579:

Execution engine

  • DML-in-CTE / read-only guarantee — the executor now builds the tables on a writable connection to a temp file, then reopens the database read-only. Every query (trusted task SQL and agent rewrites) runs on that read-only connection, so the DuckDB engine rejects any write regardless of form — including data-modifying CTEs — on any version, with no keyword heuristics. Test drives a WITH … DELETE and asserts it's rejected and the tables are untouched.
  • Shared connection concurrency — a single DuckDB connection isn't safe for the server's worker pool, so all connection access is now serialized under a lock. Added a 6-thread concurrency test.
  • Removed the now-redundant BEGIN/ROLLBACK isolation (the read-only connection is the guarantee) and added atexit cleanup of the temp DB dir.

Scoring

  • Speedup awarded without a correctness gate — _speedup_score is now gated on results_match, so a trivially fast but wrong rewrite (e.g. SELECT 1 vs a heavy original) earns 0 for speed. Test asserts this.

Tasks

  • Task 2 last_order_amount unstable tie — added a unique id tie-breaker (ORDER BY created_at DESC, id DESC), so the subquery (and any correct rewrite) is deterministic.
  • Task 1 rewrite couldn't satisfy correctness — reframed like tasks 3/5: the required rewrite makes the CAST/year() predicates sargable while keeping the projection (result-preserving); SELECT * is a detect-only issue. Test asserts the intended rewrite matches.

Verified: 28 passed (13 original + 15 regression), ruff + sync_env_docs --check clean, live WebSocket round-trip green.

Comment thread envs/sql_optim_env/server/executor.py Resolved
Comment thread envs/sql_optim_env/server/executor.py Resolved
@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Addressed in HEAD (force-pushed). Both were false-rejections in the structural pre-check, so I removed the pre-check entirely: the read-only connection is the actual guarantee, so any write (DDL/DML, in a CTE, or after a ;) is rejected by the DuckDB engine and surfaces as optimized_error — no up-front SELECT/WITH check that could trip on a leading --//* */ comment or a ; inside a string literal.

Added a test that valid rewrites using REPLACE(...), a 'set' / 'a;b' literal, or a leading comment all run, while DROP / DELETE / SELECT 1; DROP … / a WITH … DELETE are still rejected and leave the tables intact.

Verified: 28 passed, ruff + sync_env_docs --check clean.

Copilot AI review requested due to automatic review settings July 23, 2026 16:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings July 25, 2026 17:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Addressed the remaining fetchall() finding in 8f0133b.

_run() no longer materializes result sets in order to measure them. Timing now comes from DuckDB's own profiler (EXPLAIN ANALYZE → Total Time), which executes the query for real and discards the result inside the engine, so no rows cross into Python. Rows are materialized only up to the 50k cap that compare() already used to pick its precise row-by-row comparison; above that, the exact row count comes from an in-engine COUNT(*) and correctness from the existing order-independent checksum. A wall-clock streaming drain is kept as a fallback for a DuckDB version whose profile footer can't be parsed, so timing degrades instead of breaking. The execution lock became reentrant so a whole measurement stays serialized as it was before.

The finding was measurable. For SELECT * FROM events, fetchall() took 783.6 ms of which DuckDB execution was 25.5 ms — 97% of the reported "execution time" was tuple construction. On this machine (duckdb 1.5.5):

before (b1776b1) after (8f0133b)
SELECT * FROM events peak Python memory 867.7 MB 14.5 MB
SELECT * FROM events reported ms 4212.0 18.5
task_5_window_functions reported ms 1945.4 359.0
task_5_window_functions compare() wall 13,644.8 ms 5,680.1 ms
all five tasks, total wall 18,038 ms 9,912 ms

Worth flagging the tradeoff rather than burying it: small-result queries now pay one extra profiled execution, so task_1_basic_antipatterns went 101 ms → 233 ms and task_4_implicit_join 1403 ms → 1856 ms per comparison. The total across all five tasks is still ~45% lower, and the pathological case is where the reward signal was actually being distorted.

Behaviour is otherwise unchanged: writes are still rejected by the read-only connection (DROP, DELETE, WITH d AS (...) DELETE, SELECT 1; DROP TABLE orders all still surface as optimized_error), row counts stay exact, and results under the cap are still compared row by row.

New TestLargeResultMeasurement class covers it. Against the previous executor the two substantive assertions fail:

assert 804.063 < (702.306 * 0.25)        # timing was reporting materialization
assert 552749658 < ((100 * 1024) * 1024) # 552 MB peak for a 500k-row compare

Env suite: 33 passed. Repo-wide (excluding tests/integration): 1539 passed, 131 skipped. ruff check and ruff format --check clean.

One note in case it comes up in a future review of this file: the over-cap probe leaves a partially-consumed result, and it deliberately isn't closed — in duckdb-python conn.execute() returns the connection itself, so close() there would close the shared read-only connection. The next statement supersedes the pending result instead; RSS stayed flat at 272 MB across 30 consecutive capped 1M-row runs. There's a comment on that in _probe.

Comment thread envs/sql_optim_env/server/executor.py Resolved
Comment thread envs/sql_optim_env/server/executor.py Resolved
Copilot AI review requested due to automatic review settings July 25, 2026 17:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Rebased onto current main, and the Bugbot finding addressed in 058fb4d2.

The finding is right, in its narrower form. _parse_result did read the envelope for StepResult, so result.reward and result.done were already correct; what it did not do was put them back on the observation it rebuilt from the nested payload:

envelope reward/done: 0.75 True
nested has reward? False | done? False
StepResult.reward: 0.75 | StepResult.done: True     <- fine
observation.reward: None | observation.done: False  <- the bug

So anyone reading result.observation.reward got None on every step, and result.observation.done stayed False after the episode ended.

That is the same defect #1017 fixed in sophistry_bench_sprint_env a few hours ago, so I followed it rather than inventing a second shape: read both from the envelope, construct the observation once with them set instead of mutating it afterwards. reset still leaves reward as None rather than coercing it to 0.0, since nothing has been scored yet.

I also added TestSQLOptimClient to tests/envs/test_client_step_result_contract.py from that PR, driven by the real serializer like the existing classes. Three tests: one fails without the client change, the other two pin reset semantics and the surrounding fields.

Worth noting for that file's stated invariant: the new class does not importorskip on duckdb, and does not need to. sql_optim_env's client and models import only openenv and pydantic, so the tests run in the root test job (verified in an environment without duckdb installed), which is what keeps this from drifting again. The engine-dependent tests remain in test_sql_optim_environment.py under the dedicated job.

On the rebased head:

  • root test job command: 1886 passed, 93 skipped
  • test-sql-optim-env command: 48 passed
  • contract file: 9 passed
  • sync_env_docs.py --check, ruff check, ruff format: clean

…izing rows

_run() fetched every result row into Python via fetchall(), three times over
for the timing median. For a query like SELECT * FROM events (1M rows) that
held the whole result in memory and made the measurement report tuple
construction rather than query execution: fetchall() took 783.6ms of which
DuckDB execution was 25.5ms, so 97% of the reported time was client-side
conversion.

Timing now comes from DuckDB's profiler (EXPLAIN ANALYZE -> Total Time),
which executes the query for real and discards the result inside the engine,
so no rows cross into Python. Rows are materialized only up to the 50k cap
compare() already used to pick its precise row-by-row comparison; above that
the exact row count comes from an in-engine COUNT(*) and correctness from the
existing order-independent checksum. A wall-clock streaming drain is kept as
a fallback for a DuckDB version whose profile footer cannot be parsed, so
timing degrades instead of breaking. The execution lock becomes reentrant so
a whole measurement stays serialized as before.

Comparing SELECT * FROM events goes from a 867.7MB peak and 4212ms reported
to 14.5MB and 18.5ms; task_5_window_functions compare() drops from 13.6s to
5.7s. Small-result queries pay one extra profiled execution (task_1 101ms ->
233ms); the total across all five tasks is still ~45% lower.

Behaviour is otherwise unchanged: writes are still rejected by the read-only
connection, row counts stay exact, and results under the cap are still
compared row by row.
…verified match credit

Two High-severity review findings on the execution engine.

The read-only connection stops the agent rewrite from changing the database,
but not from reaching the filesystem. On the previous commit an agent-authored
query could run `COPY ... TO` to write an arbitrary file (verified: the file
was created), `read_csv('/etc/passwd')` to read one, plus `read_text`,
`read_blob`, `read_parquet`, `glob`, `COPY FROM`, `EXPORT DATABASE`, `ATTACH`
and `INSTALL`/`LOAD`. Both connections now open with
`enable_external_access=false`, and with `lock_configuration=true` so a
rewrite cannot `SET` or `PRAGMA` its way back out (DuckDB additionally refuses
to re-enable external access on a running database). The escape hatches are
blocked on the `EXPLAIN ANALYZE` timing path as well.

Second, a rewrite ending in a `--` comment silently bought full correctness
credit. The checksum wraps the query in a subquery, and on a single line the
trailing comment swallowed the closing parenthesis, so the checksum failed and
equal row counts alone were reported as a match: `SELECT id FROM events -- c`
against `SELECT id + 1 AS id FROM events -- c` matched on a million rows that
all differed, unlocking the speedup slice too. `_as_subquery()` now puts the
query on its own line, and an over-cap result with no usable checksum reports
`results_match=False` instead of guessing, as does the surrounding except
path.

Note that the review's stated mechanism for the second finding -- `str.format`
raising on braces in the query -- does not reproduce: `format()` does not
rescan substituted values, and a brace-containing query checksums normally.
The trailing-comment path is the one that was exploitable.

TestSandboxAndCorrectnessCredit covers both; five of its six tests fail on the
previous commit. 38 env tests pass, 1544 repo-wide, ruff clean.
… SQL

The agent chooses the SQL, so `SELECT COUNT(*) FROM events a, events b` is a
trillion-row cross join. It ran unbounded on the shared read-only connection
while holding the execution lock, so a single rewrite could wedge the server
and queue every other session behind it, and nothing capped memory.

DuckDB has no statement-timeout setting, so `_deadline()` arms a timer that
calls `interrupt()` on the connection, which cancels the running query and
leaves the connection usable. It is armed by every helper that executes agent
SQL and around the whole of `_run`, so a helper is bounded whoever calls it
while a measurement is bounded as a whole. `memory_limit` and
`max_temp_directory_size` cap memory and spill space.

`duckdb.InterruptException` is handled explicitly everywhere rather than
falling into the generic handlers, because a timeout must not be read as "that
strategy failed, try another one": `_row_count` would have answered a cancelled
count by starting a streaming drain of the same runaway query with the timer
already spent, and `_engine_ms` would have fallen back to wall-clock timing the
same way, so the deadline bought nothing. Trusted queries (`table_stats`) are
deliberately left without a deadline, since arming one there would only add a
window in which a stray interrupt could cancel them.

Also fixes `explain()`, which this change had broken: the success return sat
after an `except` clause as dead code, so the method returned None. Nothing in
the repo covered `explain()`, which is why a full green run missed it; it now
has tests.

Verified: the cross join is cancelled at the deadline and reported as
`Query cancelled: exceeded the 15s execution limit`, the connection stays
healthy, and a second session gets the lock instead of waiting indefinitely.
The five real tasks are unaffected -- their slowest engine time is 392ms, and
an A/B over medians shows 10011ms before against 9950ms after across all five,
so the limits cost nothing measurable.

TestResourceLimits covers it; the runaway test hangs until pytest-timeout kills
it on the previous commit. 44 env tests pass, 1550 repo-wide, ruff clean.
The environment landed before the per-env packaging conventions this repo
now uses, so on current `main` it would take the shared test job down with
it: `tests/envs/test_sql_optim_environment.py` imports `duckdb` at module
scope, which the root `test` job does not install, and a collection error
there fails the whole job rather than one file.

Follow the pattern `thinkingbox_env` established:

- Ignore the SQL tests in the root `test` job, alongside the other
  environments whose dependencies are not in the root install, and add a
  `test-sql-optim-env` job that syncs `envs/sql_optim_env` and runs them
  for real on 3.11 and 3.12.
- Add `envs/sql_optim_env/uv.lock`. The `validate-env-locks` job runs
  `uv sync --frozen` for every environment whose `pyproject.toml` changes,
  which cannot succeed without a lockfile.
- Raise the `openenv` floor to `>=0.4.1` and `requires-python` to `>=3.11`
  so the locked resolution matches the core release and the CI matrix.
- Declare the `validation:` block from RFC 008, so
  `openenv validate envs/sql_optim_env --level static` reports PASS. The
  declared reward range, memory bound and query timeout are the values the
  executor actually enforces.
- Add `.dockerignore` so the image build does not copy `.venv` or test
  artifacts.
A read-only DuckDB database rejects persistent writes, but it still
accepts session-scoped DDL. `CREATE TEMP TABLE users AS SELECT 1` is
therefore accepted on the shared read-only connection, and the temporary
object shadows the base table for every later query in the process:

    baseline: [('real',)]
    ACCEPTED: CREATE TEMP TABLE users AS SELECT 2, 'HIJACKED'
    after:    [('HIJACKED',)]

That is a reward-hacking path, not just leakage between episodes. An
agent can shrink `orders` to a single row, and because the original
query is measured against the same shadowed table, its rewrite comes out
both instant and "correct".

This corrects an earlier claim in this PR. Previous rounds argued the
read-only connection was a complete guarantee, that "any write (DDL/DML,
in a CTE, or after a `;`) is rejected by the DuckDB engine", and dropped
the structural pre-check on that basis. The engine rejects persistent
writes only; the comments asserting otherwise have been corrected.

Queries now run on a short-lived `self.conn.cursor()`, whose temporary
catalog is discarded when it closes, so nothing an agent creates
survives its own statement. Measured cost is 0.15 ms per query.

The deadline resolves its target when the timer fires rather than when
it is armed, so a deadline armed around a whole measurement still
cancels the individual statement running underneath it.

Four regression tests; three of them fail without this change, the
fourth asserts that DDL is still never credited. DDL previously surfaced
as an error only as a side effect of the leak (the temp table survived
into the second timing run and collided with itself), so that behaviour
is deliberately not preserved: it is unnecessary, because the grader
gates speedup credit on `results_match`. Submitting DDL as a rewrite
scores 0.13 against 0.32 for an honest no-op rewrite.
`serialize_observation` keeps `reward` and `done` on the response
envelope and strips them from the nested observation payload. The client
read the envelope for `StepResult`, so `result.reward` and `result.done`
were right, but it rebuilt the observation from the nested dict alone:

    envelope reward/done: 0.75 True
    nested has reward? False | done? False
    StepResult.reward: 0.75 | StepResult.done: True
    observation.reward: None | observation.done: False

So a caller reading `result.observation.reward` saw `None` on every
step, and `result.observation.done` stayed False after the episode
ended.

This is the same defect openenv#1017 fixed in `sophistry_bench_sprint_env`, so
the fix follows it: read both from the envelope, build the observation
once with them set rather than mutating it afterwards. `reset` still
leaves `reward` as `None` instead of coercing it to 0.0, since nothing
has been scored yet.

Adds `TestSQLOptimClient` to the contract file openenv#1017 introduced, driven
by the real serializer like the others. The first test fails without the
client change; the other two pin `reset` semantics and the surrounding
fields. These tests deliberately do not skip when duckdb is absent: the
client and models import only openenv and pydantic, so they run in the
root job, which is what keeps the client and the wire format from
drifting apart again.
@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (f9b1381d); this had gone conflicting behind 18 commits and is mergeable again.

One real conflict, in docs/source/environments.md: QED Math landed in the same grid slot as this environment's card. Both are kept as separate cards.

While resolving it I also corrected the card's description. It still said "in-memory DuckDB", which stopped being true in 058aac98: the database is built on a temporary file and reopened read-only, which is the part that actually does the sandboxing. That was the last place the old wording survived.

No functional changes in the rebase. The earlier Bugbot finding on the client is still addressed in 058fb4d2, now f9b1381d, which mirrors reward and done back onto the observation and adds TestSQLOptimClient to tests/envs/test_client_step_result_contract.py.

Checks on the rebased head:

  • test-sql-optim-env command plus the contract file: 57 passed
  • root test job command: 2602 passed, 117 skipped
  • python scripts/sync_env_docs.py --check: clean
  • uv sync --frozen lock check for envs/sql_optim_env: clean
  • ruff check: clean

This one has not had a human review yet. Whenever someone has time, the fork workflow runs will need the same one-off approval that unblocked #1008 before exact-head CI can report here.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread envs/sql_optim_env/server/executor.py Resolved
`_engine_ms` prefixes `EXPLAIN ANALYZE ` onto the agent's query to time
it. A prefix only binds to the first statement, so a rewrite of the form
`SELECT 1; SELECT * FROM events` leaves the real query running as a
statement of its own, and the plan fetch returns that result set instead
of a plan. `_MAX_MATERIALIZED_ROWS` is bypassed entirely, and the
deadline does not help because `interrupt()` cannot stop rows already
being converted into Python objects.

Measured through `compare()` against the 1M-row `events` table:

    honest rewrite    peak=   0.0 MB  wall=    14 ms
    multi-statement   peak= 343.8 MB  wall= 15015 ms

The 15s is the query timeout being reached while holding the shared
execution lock, so this is a memory spike and a denial of the executor
to every other session, from a single submitted rewrite.

Agent SQL is now required to be exactly one statement, decided by
`duckdb.extract_statements` rather than by scanning for `;`. That
distinction matters: a leading `--` comment, a block comment, a `;`
inside a string literal, and a trailing terminator all stay valid, which
is what the keyword pre-check removed in an earlier round got wrong.
The check lives in `_probe`, which `_run` stops on, so nothing executes
before the rejection. `explain()` gets the same guard on its own path.

The plan fetch is also bounded now, as defence in depth rather than as
the fix: a plan is a couple of rows, so anything larger is not a plan.

After the change the same attack is `peak=0.0 MB wall=6 ms`, reported as
an error. Four parametrized cases pin the legitimate rewrites that must
keep working.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread envs/sql_optim_env/openenv.yaml Resolved
@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

thanks for picking this up, and for dec1148. the half-the-envelope test is a better check than what i had.

on the fingerprint gate: if the probabilistic part is the concern, the large-result path could use an exact multiset check in duckdb instead, (a EXCEPT ALL b) UNION ALL (b EXCEPT ALL a) and require 0 rows. i tried it on 1.5.5 against 1M rows: reordered identical results come back 0 in ~170ms, id vs id+1 and dup-vs-distinct are caught, NULLs compare equal. happy to do that here or as a follow-up, whichever you prefer. otherwise i will stay off the branch so i do not step on your commits.

@burtenshaw

Copy link
Copy Markdown
Collaborator

Thanks — independently reproduced the useful part on pinned DuckDB 1.5.5: the materialized-CTE symmetric EXCEPT ALL check passes reordered 1M rows, catches shifted IDs and changed duplicate multiplicities, and treats NULLs equally. All five task self-comparisons also pass in a real 1 GiB container with the current 256 MiB buffer / 128 MiB spill caps.

There is a concrete equality-contract decision before this can be a mechanical replacement. At dec11483, the current comparator rejects both of these pairs (tested with 2 and 50,002 rows), while raw EXCEPT ALL accepts them:

  • 9007199254740993::BIGINT AS x vs 9007199254740992::DOUBLE AS x — different values collapse through coercion.
  • true AS x vs 1 AS x.

DuckDB documents combination casting for set operations. Aliases, decimal scale and signed zero also differ. The existing implementation is itself inconsistent across the threshold: 1 AS x vs '1' AS x is rejected for 2 rows but accepted for 50,002; changing only alias x to y does the reverse. Exact comparison of the existing serialized row strings would remove hash collisions but retain those serialization issues, so it would not establish exact typed-value equality.

My recommendation is to settle one schema/type/float equality rule for both paths first (including empty results), then implement the exact multiset check with coercion guarded according to that rule. Row order is already ignored; duplicate multiplicity must remain significant. Use explicit materialization for each operand so the two EXCEPT arms share one evaluation, keep parser validation and newline-safe wrapping, retain the locked session/deadline and fail closed on comparison errors. This still compares fresh executions after timing; it does not prove equivalence of volatile queries such as random().

No branch changes in this round: the requested simple-fix scope does not authorize choosing these reward semantics. Focused suites pass 99 tests on each of Python 3.11/3.12. The container probe also verified interruption, subsequent connection reuse, session cleanup, multi-statement rejection and denied filesystem access. Its peak across all probes/tasks was 605,560,832 bytes; that is workload evidence, not a Python heap or hostile-query bound. The process/container availability policy remains a separate maintainer gate (@Darktex).

@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

agreed on settling the rule first. proposal, one rule for every result size:

  1. schema: same column count, same names (case-insensitive, like duckdb identifiers) and identical types from DESCRIBE, including decimal precision/scale. any difference is a mismatch, so EXCEPT ALL never sees mixed types and combination casting can't happen. that rejects both of your pairs, and 1 vs '1', at any size.
  2. rows: multiset equality via materialized operands and symmetric EXCEPT ALL. order ignored, multiplicity counts.
  3. values: duckdb's own equality with no tolerance. -0.0 = 0.0, NaN = NaN, NULL = NULL (checked on 1.5.5). the seeded money columns are DECIMAL, and AVG(total) gave one value across 1 to 8 threads and matched sum/count on all 10k groups, so i don't see a case for a float tolerance here.
  4. empty results match only when the schemas match.
  5. the 50k str-compare path and the hash checksum both go, so nothing depends on result size anymore.
  6. same session, lock and deadline. any error or timeout counts as a mismatch.

volatile queries stay out of scope. if this works for you i'll implement it on the branch with a test per rule.

@burtenshaw

Copy link
Copy Markdown
Collaborator

Thanks — technically, I accept this as a coherent comparison policy for implementation. This is acceptance of the proposed equality contract, not approval of the current implementation or merge/resource-isolation gates.

I independently ran DuckDB 1.5.5 probes: 66 cases across 0/2/50,002 rows, plus million-row reorder/shift and duplicate-multiplicity cases. The schema guard rejects the earlier lossy BIGINT/DOUBLE and INTEGER/VARCHAR examples; signed zero, NaN, NULL and exact float comparison behave as proposed. Strict types/names and no float tolerance are intentional compatibility choices; the old size-dependent behavior need not be preserved.

Please encode these details in implementation/tests:

  • Compare original output schemas by position, before wrapper/CTE/temp-table column deduplication. SELECT 1 x, 2 x and SELECT 1 x, 2 x_1 have different original names, but DESCRIBE SELECT * FROM (...) reports (x,x_1) for both. That wrapper would incorrectly erase the mismatch.
  • “Like DuckDB identifiers” means ASCII-only case folding: X/x match, Ä/ä do not. Python lower()/casefold() is broader. Compare full DESCRIBE type strings without case-normalizing them: INT4 canonicalizes to INTEGER, including inside lists; STRUCT(A INTEGER) and STRUCT(a INTEGER) remain different strings under this strict policy. Include nested types and decimal precision/scale tests.
  • Keep each validated operand materialized once for both EXCEPT arms; preserve its original schema association and positional order. Include schema-aware empties, duplicate aliases, multiplicity, and cases on both sides of 50k. Return only the comparison scalar to Python; remove both old equality paths. Engine set-operation equality also intentionally permits e.g. INTERVAL '1 month' versus INTERVAL '30 days'; this is not byte/representation identity.
  • Cover schema inspection, materialization and comparison with the same session/lock and an actual shared expiration check. The existing _deadline() timer alone is insufficient across statements: with a 10ms deadline, DESCRIBE followed by a 30ms idle gap still allows the next SELECT 1 to succeed. An interrupt on an idle connection does not latch cancellation. Check expiry before subsequent stages and before awarding credit; join the timer before releasing the lock. Test interruption during materialization, expiry between stages, mismatch on errors, and successful subsequent-session recovery, with no fallback retry.

The exact comparison adds engine memory/spill work; the existing 256MiB/128MiB caps and fail-closed errors remain relevant. It does not bound _probe/timing-path Python conversions or establish process containment. The separate resource-isolation decision remains open. Volatile queries remain outside this contract, and exact float equality does not promise all aggregate rewrites are numerically reproducible. Please implement on your branch; this review made no source changes.

Replace the row-string comparison below 50k rows and the row-hash
fingerprint above it with one exact check for every result size.

Output schemas are compared by position from a raw DESCRIBE: same
column count, ASCII-only case-insensitive names and identical DuckDB
type strings. Only then are the rows compared as a multiset, with one
materialized CTE per operand and a symmetric EXCEPT ALL, so set
operation type coercion cannot make true equal 1. Only the count of
differing rows returns to Python.

Schema inspection and comparison share one session, lock and deadline.
Expiry is checked before each stage and before a match is reported, and
any error or timeout is a mismatch with no fallback. Because an
interrupt sent to an idle connection is not remembered, the deadline
watchdog keeps re-sending it after expiry until the context exits.
@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

implemented in 4cd0937.

  • schemas compared by position from raw DESCRIBE, ascii-only name folding, type strings exact
  • one materialized CTE per operand, symmetric EXCEPT ALL, only the count comes back. the row-string and hash paths are gone
  • schema inspection and comparison share one session, lock and deadline, with expiry checked before each stage and before credit. any error or timeout is a mismatch, no retry
  • an idle interrupt doesn't latch, so _deadline now re-sends it every 10ms after expiry until the context exits

TestExactComparison runs each type/name/value case at 2 and 50,002 rows, plus empties, multiplicity, single materialization (one scan per table in the plan), the comparison CTE names being unreadable from a rewrite, and the deadline cases you listed. 21 of the new tests fail on dec1148. sql-optim suite + client contracts: 123 passed on 3.11 and 3.12.

unrelated heads-up: locally the root test job fails collecting test_qed_math_environment.py with the unpinned datasets 5.1.0 (it imports httpx from huggingface_hub.utils, which 1.30.0 doesn't have).

@burtenshaw

Copy link
Copy Markdown
Collaborator

Independently reviewed 4cd0937475aeacdfbcc68afdac3d4330292e6e0b. The implementation satisfies the agreed comparison contract in the cases tested; I found no new mechanical blocker in this change. Raw positional DESCRIBE preserves duplicate labels, ASCII folding preserves Unicode distinctions, and exact type checks precede symmetric EXCEPT ALL for every result size, including empties. The old string/hash paths are removed.

Validation with DuckDB 1.5.5:

  • SQL environment + client contracts + state wiring: 153 passed on Python 3.11 and 3.12 (the extra state-wiring suite explains the difference from your 123).
  • Independent production-comparator probes: 154 policy cases at 0/1/2/49,999/50,000/50,001/50,002 rows, plus 13 large multiset, NULL/NaN multiplicity, CTE visibility/shadowing, parser/external-access and volatility cases.
  • Runtime side-effect counters observed exactly 100 calls per 100-row operand across both difference arms, supplementing the plan test. Ten concurrent timeout/recovery rounds left no sessions or watchdog threads behind. A real DuckDB OOM under a deliberately tiny private 8MiB/no-spill probe budget withheld credit and allowed a subsequent comparison to succeed.
  • Scoped Ruff, formatting, usort and diff checks pass. Existing tests cover expiry between stages/before credit and joining interruption before lock release. Comparison errors have no retry/fallback.

This accepts the comparison-policy implementation, not overall merge readiness. The independent resource-isolation gate remains: the unchanged 256MiB/128MiB caps do not bound Python conversion or total process memory; cooperative interruption is not a hard wall-time/process boundary. Volatile SQL remains outside the contract. No new full-server/container containment validation was performed.

I also reproduced the reported datasets==5.1.0 / huggingface-hub==1.30.0 ImportError in an isolated environment. A clean resolver rejects that pair because datasets 5.1.0 requires hub >=1.31.0; forcing the incompatible installed pair produces the missing httpx import. This did not affect the focused tests above, and no repository dependencies were changed. No source edits or thread resolutions in this round; all 30 existing threads were already resolved.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4cd0937. Configure here.

Comment thread envs/sql_optim_env/server/executor.py Resolved
@OfficialAbhinavSingh

Copy link
Copy Markdown
Contributor Author

checking in since it's been a few days quiet after the terminator fix. is there anything i can do on the resource-isolation side, or someone else who could weigh in if darktex isn't available right now? no rush if this needs more time, just want to make sure it's not stuck waiting on me.

Resolve docs index conflicts with the OR-Tools environment (openenv#1501) by keeping both entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@burtenshaw

Copy link
Copy Markdown
Collaborator

I merged main into this branch to fix a conflict in the docs index with the OR-Tools environment (#1501). Both entries are kept, and python scripts/sync_env_docs.py --check passes. Please pull before you push again.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants