Add SampleSource for dynamic sample generation within a task#4446
Add SampleSource for dynamic sample generation within a task#4446ransomr wants to merge 12 commits into
Conversation
Mirrors the TaskSource pattern at the sample level: pass a SampleSource as a Task's `dataset` to drive the task from code — a synchronous seed (`initial_samples`, may be empty), per-result follow-ups returned from `sample_complete`, and a blocking `next_samples()` pull that ends the task when it returns None. Useful for RL loops and adaptive evals. - Live dispatcher in task_run (mirrors run_multiple's feed loop): injected samples start immediately on free capacity; next_samples() is only awaited when fully idle (no lost wakeups) - enqueue_sample() imperative primitive (ContextVar-scoped per task), mirroring enqueue_task; requires a SampleSource-driven task - Auto-ids for injected samples continue the seed's numbering (str-form uniqueness, matching ensure_unique_ids); duplicate ids are an error - Planned totals grow as samples are added: display denominator, fractional fail_on_error threshold, and control-channel counters (new record_samples_added in eval_state) - Injected samples honor the task's epochs, like the seed - Wake moved from _eval/run.py to _util/_async.py for reuse Fixes #36 Co-authored-by: Ransom Richardson <ransomr@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ew feedback --limit now caps a SampleSource task's total samples (seed + produced): the seed is sliced as usual and the remainder is the budget for samples added while the task runs — additions beyond it are ignored with a warning, and once exhausted the dispatcher finishes without consulting next_samples() again. --sample-id filters produced samples with the same normalise+fnmatch predicate as the seed, and ids missing from the seed no longer warn or error for SampleSource tasks (the source may produce them at runtime; seed datasets are marked via DATASET_SAMPLE_SOURCE_ATTR). Review fixes: samples enqueued during a terminal next_samples() are drained and run instead of silently dropped; an anyio checkpoint keeps the dispatch loop cancellable if a source never blocks; the dispatcher's inner-exception unwrap now raises `from None` matching tg_collect; docs note that sample_complete fires per epoch and follow-ups multiply. Co-authored-by: Ransom Richardson <ransomr@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…706-1823 # Conflicts: # CHANGELOG.md
…eed-sized log dataset spec Addresses the two review nits from the latest review round: - SampleEnqueuer paired a threading.Lock with a wake callback (anyio.Event.set) that is not thread-safe, signalling two conflicting intents. Resolve by picking the loop-only intent: drop the lock (the buffer ops have no yield points under the event loop) and document in SampleEnqueuer and enqueue_sample() that calls must come from the task's event loop, not a worker thread. - Document the seed-sized log dataset spec as a known limit: the finished log's eval.dataset stays seed-sized (intentionally — it is load-bearing for retry reuse via eval_log_sample_source), so spec consumers (analysis evals dataframe, crash recovery) under-count for dynamic tasks. Noted in design/sample-source.md known limits and as a user-facing "Logs" note in docs/sample-source.qmd. Co-authored-by: Ransom Richardson <1209015+ransomr@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI run 29032396298 failed only because uv got a 504 Gateway Timeout fetching the python-build-standalone 3.10 archive from GitHub releases; no code changes needed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nload Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…706-1823 # Conflicts: # src/inspect_ai/_control/eval_state.py
… into claude/issue-36-20260706-1823
|
|
||
| Called once, synchronously, when the ``Task`` is created — so it must | ||
| return immediately (no awaiting / blocking). The returned samples drive | ||
| the task's up-front setup (validation, sandbox startup) and are the |
There was a problem hiding this comment.
Is passing an initial samples required to get a sandbox started (or would it start downstream if I omitted this and provided next samples)? Any other configuration type things that would only be configured if initial_samples has content?
There was a problem hiding this comment.
Good question — as of b3d5e94, no: the seed isn't required for sandboxes, and there is no other configuration that only happens when initial_samples() has content.
Some background: per-sample sandbox creation (sample_init / compose up) always happens at sample run time, wherever the sample came from. What was keyed off the seed was the run-level startup pass (SandboxManager), which discovered sandbox configs only from the seed dataset — so a config first seen in an added sample (or the task-level config when the seed was empty) missed task_init: no up-front image build/pull (each sample's own compose up would pull on demand, racing concurrent samples), no fail-fast validation, and no registered task_cleanup at end of run.
b3d5e94 routes added samples through the same incremental sandbox startup pass used for injected tasks: the dynamic dispatcher awaits task_init for not-yet-started configs before spawning each added batch (already-started configs are a set-membership no-op, so the common same-config-as-seed case costs nothing). A source driven entirely by next_samples() now behaves identically to a seeded one.
Everything else was already handled equivalently downstream for added samples: id assignment/uniqueness, total-sample accounting (fail_on_error denominator, progress), --limit / --sample-id, and the max_sandboxes limiter (re-registered idempotently per sample). Updated the docstring here and added a Sandboxes section to docs/sample-source.qmd to document this.
There was a problem hiding this comment.
Follow-up: a review pass over the whole PR refined the "everything else is handled equivalently" part of the answer above — three interactions turned out not to be equivalent for dynamic tasks, and df9c1f5 changes their behavior:
early_stoppingis now rejected (PrerequisiteError, pre-run). Managers register a fixed sample set atstart_task— samples the source adds were never registered — and samples a manager halts complete without notifying the source, which would hang a source blocked awaiting completions.- Range
--limit(start,end) is now rejected forSampleSourcetasks. It selects seed samples by position, but added samples have no position (the previous behavior was incoherent:--limit 10,20on a 3-sample seed ran zero seed samples yet admitted 10 additions). Plain numeric--limitstill caps the total as documented. - Fractional
fail_on_errordefers to the end-of-run check. The planned total grows while the task runs, so the mid-run check measured early errors against a transiently small denominator (a 1-sample seed erroring first tripped1 >= 0.5*1before the source produced anything). Absolute-count and any-error thresholds still abort mid-run.
Also in df9c1f5, related fixes surfaced by the same review: task retries now re-fire sample_complete for reused samples so completion-driven sources reconstruct their runs (previously their follow-up samples were silently absent from retry logs); the control channel no longer reports an idle dynamic eval (e.g. blocked in next_samples() with an empty seed) as finished, so ctl task cancel works during that window; and added samples' memory is bounded (slots released after their last epoch). Docs (sample-source.qmd) and the design doc cover the new constraints and the retry contract.
Previously only the seed dataset drove the run-level sandbox startup pass, so a sandbox config first seen in an added sample (or a task-level config with an empty seed) never got task_init — no up-front image build/pull, no fail-fast validation, and no registered task_cleanup. Route added samples through the same incremental SandboxManager pass used for injected tasks: the dynamic dispatcher now awaits sandbox startup for each added batch before spawning it, with already-started configs a cheap no-op and initialization serialized against concurrent live tasks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md # src/inspect_ai/_eval/run.py
…ol channel Correctness fixes from a full review pass of the SampleSource feature: - Task retries: the prior-attempt reuse path now re-fires sample_complete for reused samples, so completion-driven sources regenerate their follow-ups instead of silently dropping them from the retry log; the same-instance retry contract is documented. - Reject early_stopping for SampleSource tasks (managers register the seed only, and halted samples complete without notifying the source, hanging sources blocked on completions) and range --limit (positional for the seed but a bare count budget for additions). Both raise PrerequisiteError in prepare_options, before the task logger exists. - Defer fractional fail_on_error thresholds to the end-of-run check for SampleSource tasks: the planned total grows while the task runs, so a mid-run check measured early errors against a transiently small denominator. - Skip the source notification for cancelled samples: it was awaited under a cancellation shield, so a never-resolving user callback made teardown uncancellable. - EvalState gains a dynamic flag: a SampleSource eval's counters reaching total no longer stamps completed_at (an idle eval blocked in next_samples() read as finished, making ctl task cancel a no-op); finalize_eval stamps at the task's true finish point. Plus efficiency and cleanup fixes: seen_ids no longer re-reads the disk-paged seed store; injected sample slots are released after their last epoch (bounded memory for open-ended sources); sandbox resolution is cached for metadata-less samples and the init lock has a pre-lock fast path; slice_dataset takes an explicit dynamic parameter instead of a setattr marker on the dataset; sample_id_filter returns a NamedTuple so slice_dataset stops duplicating its normalization; SandboxManager's resolve loop is shared between start and start_for_samples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This PR contains:
What is the current behavior? (You can also link to an open issue here)
Fixes meridianlabs-ai#36
A task's
datasetis static: you pass aDataset(or list of samples) and the task runs exactly those. There is no way to generate a task's samples dynamically based on results (for RL or adaptive evals) — theTaskSourcepattern exists only at the eval level, where it produces whole tasks.What is the new behavior?
SampleSourcemirrors theTaskSourcepattern at the sample level: pass one as thedatasetargument toTaskto generate samples from code while the task runs, all within one task / eval log.TaskSource: a synchronous seed, per-result follow-ups returned fromsample_complete(the simplest sources need nonext_samples()), and a blockingnext_samples()for the explicit-pull case.SampleSource.from_samples(...)builds a source from a seed + callbacks without subclassing.task_rungains a dispatcher (mirroringrun_multiple's feed loop) — added samples start immediately on free capacity (bounded bymax_samples);next_samples()is only awaited when fully idle, so there are no lost wakeups. Plain tasks keep the unchangedtg_collectpath.enqueue_sample()imperative primitive (mirrorsenqueue_task): add samples to the running task from any code (solver / scorer / tool), ContextVar-scoped per task. Raises a clear error outside aSampleSource-driven task.fail_on_errorthreshold, control-channel counters via a newrecord_samples_added).--limit/--sample-id:--limitcaps the total samples (seed + added; additions past the cap are ignored with a warning) and--sample-idfilters added samples the same way it filters the seed.Wake(re-armable wake signal) moved from_eval/run.pyto_util/_async.pyfor reuse by the new dispatcher.Docs:
docs/sample-source.qmd(+ nav/reference entries). Design notes:design/sample-source.md. Tests:tests/test_sample_source.py(19 tests, including a liveness test that discriminates live injection from batch-at-a-time).Does this PR introduce a breaking change? (What changes might users need to make in their application due to this PR?)
No.
Task(dataset=...)accepts a new type; existing datasets behave identically (plain tasks take the unchanged fixed-set path).Taskinstances gain asample_sourceattribute (Noneunless a source was passed).Other information:
Verification:
pytest tests/test_sample_source.py tests/test_task_source.py tests/test_enqueue_task.py tests/test_eval.py tests/_control tests/display tests/_eval tests/dataset tests/test_fail_on_error.py tests/test_retry_on_error.py tests/test_task_with.py tests/test_sample_id.py tests/test_eval_no_samples.py tests/test_disk_sample_store.py tests/test_score_on_error.pyall pass;ruff checkandmypyclean on changed files.Known limits (documented in
design/sample-source.md): task-level retries reuse completed injected samples only where the source regenerates the same ids (the same determinism contract asTaskSource+ eval_set); early-stopping managers see only the seed atstart_task.🤖 Generated with Claude Code