Skip to content

Create sandbox scoring pods on-demand - #55

Closed
bill-bateman wants to merge 2 commits into
mainfrom
scoring-sandbox
Closed

bill-bateman wants to merge 2 commits into
mainfrom
scoring-sandbox

Conversation

@bill-bateman

Copy link
Copy Markdown
Collaborator

Previously we had 2 sandbox environments at all times. This meant the 32GiB scoring pod was sitting idle for most of the time, which costs money.

Here, we spin up a scoring pod on-demand during the checker. Then we execute the same set of commands to run the scoring. We manage the scoring pods via K8sSandboxEnvironment calls directly - .sample_init and .sample_cleanup.

Some notes:

  • docker remains unchanged - brings up 2 containers at the start
  • scoring pods do not count against max_sandboxes
  • scoring pods are brought up within the same sandbox namespace, so the janitor should clean them up if anything goes pear shaped
  • currently doesn't obey --no-sandbox-cleanup flag, i.e. all scoring sandbox pods get cleaned up

Comment thread apn/checker.py Outdated
Comment thread apn/sandbox.py
Comment thread apn/sandbox.py Outdated
Comment thread apn/sandbox.py Outdated
Comment thread apn/checker.py
Comment thread apn/task.py
"mem_limit": f"{AGENT_MEMORY_GIB}g",
"network_mode": "none",
},
"comparator": {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

leaving comparator sandbox environment alone for docker runs, since I didn't see any point bringing it up/down

@bill-bateman

bill-bateman commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

Testing live, with the following yaml (based on oeis-open-lite.yaml, using existing smoke_easy.json subset)

YAML
# Schema: https://github.com/METR/hawk/blob/main/hawk/hawk/api/EvalSetConfig.schema.json
#
# oeis-smoke-easy: end-to-end shakeout of the eval pipeline on the smoke_easy
# subset -- 10 OEIS conjectures with known outcomes (5 provable, 5 disprovable).
# Not a benchmark run; keep the caps small so a broken pipeline fails cheap. 
name: oeis-smoke-easy-k8s-main
tasks:
  - package: git+ssh://git@github.com/epoch-research/LeanOpenProblems.git
    # @1122b2c563ca9506969cd719937fa6f819ff3f45
    name: apn
    items:
      - name: apn_oeis
        args:
          subset: smoke_easy
          agent_type: react
          literature: false
          sandbox_backend: k8s
models:
  - package: git+https://github.com/epoch-research/benchmarks
    name: epoch
    items:
      - name: claude-haiku-4-5-20251001
        args:
          config:
            max_retries: 7
            max_connections: 10

# 1 model x 10 samples x $2/sample cap = $20 max.
cost_limit: 2.0
working_limit: 600  # 10min * 60s

# $ per 1M tokens; keys must match Inspect's resolved <provider>/<model> names.
model_cost_config:
  epoch/claude-haiku-4-5-20251001:
    input: 1.00
    output: 5.00
    input_cache_read: 0.10
    input_cache_write: 1.25
    

secrets:
  - name: ANTHROPIC_API_KEY
  - name: LEAN_OPEN_PROBLEMS_IMAGE_NAME
    description: "The ECR repo containing the LeanOpenProblems sandbox images"

runner:
  memory: 200Gi
  environment:
    # TODO: Remove once default in Hawk
    INSPECT_LOG_CONDENSE: "true"
    ANTHROPIC_BASE_URL: https://api.anthropic.com
    HAWK_RUNNER_REFRESH_CLIENT_ID: ""
    HAWK_RUNNER_REFRESH_TOKEN: ''

packages:
  # Right now needs to be kept in sync with epoch-research/benchmarks@main.
  # TODO Remove once this wart is no longer necessary.
  - inspect-ai==0.3.259

retry_on_error: 2  # https://github.com/epoch-research/epoch-hawk/pull/13
  • run from main, to check things work
    • it had 10min to run
    • while running, the pods list contained comparators and agents
Pods

    agent-env-73d3yu8d-comparator-0 sandbox Running 2/2 ready
    agent-env-73d3yu8d-default-0 sandbox Running 2/2 ready
...
  • run from branch (revision 1122b2c563ca9506969cd719937fa6f819ff3f45)
    • it had 10mins to run
    • while running, the pods list only contains 1 default for each sample (and one runner pod)
      • i.e. no long-running scoring pods!
    • and scoring still succeeds!
    • my new info line says sample=oeis_A229969_conjecture Installed scoring sandbox release in 2.3s
      • I do wonder if it will always be that fast
  • for reference, a run from an earlier branch that failed on every scoring attempt
    • error was because the helm install needs a default service, and I had kept it as comparator
    • fix was to just call it default

Ideally I would at some point run it again and watch via kubectl the pods come up and down. Especially to see that there's no funny business when the scorer pod comes up and down.

edit: made a minor code change (making a param required when creating checker instead of giving a default), which is why the commit sha won't line up anymore

Comment thread apn/sandbox.py Outdated
@bill-bateman
bill-bateman force-pushed the scoring-sandbox branch 5 times, most recently from c8f6a82 to 0e642cc Compare September 14, 2026 23:45
@bill-bateman
bill-bateman marked this pull request as ready for review September 14, 2026 23:45

@edkins edkins left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Some comments from Claude

Comment thread apn/task.py Outdated
Comment thread apn/sandbox.py Outdated
from k8s_sandbox import K8sSandboxEnvironment, K8sSandboxEnvironmentConfig

task_name = _get_task_name()
config = K8sSandboxEnvironmentConfig(values=Path(values_path).resolve())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Needs investigation: this release bypasses Hawk's sandbox patching. In production the runner (with HAWK_RUNNER_PATCH_SANDBOX, always set by the deployed job template) rewrites every sample's sandbox values in hawk/runner/run_eval_set.py::_patch_sample_sandbox. The comparator used to ride through that path; this release does not, so it gets none of:

  • pod labels inspect-ai.metr.org/job-id, sample-id, task-name, app.kubernetes.io/component: sandbox. Hawk monitoring and the viewer's pod list select sandbox pods by exactly these labels, so "the pods list only contains one default per sample" is also what you'd see if the scoring pods exist but are unlabelled. The planned kubectl watch of the sandbox namespace should settle that.
  • karpenter.sh/do-not-disrupt: "true". Without it Karpenter can consolidate the node under a running comparator and kill an up-to-1h check.
  • the kubernetes.io/arch nodeSelector and arm64/GPU tolerations.
  • every isolation control (runtime class, securityContext, egress). Our configs don't set an isolation level today, but if one ever does the scoring pod silently gets none of it.
  • the EKS-mirrored corednsImage, and restarted_container_behavior="raise" (Hawk sets that on agent sandboxes; here it defaults to "warn", so a comparator container restart mid-check warns and continues instead of failing the check).

One option that needs no Hawk change: at score time, read the patched agent values file from the active sample's sandbox config and copy its labels, annotations, nodeSelector and tolerations into the scoring values. At minimum, set restarted_container_behavior="raise" here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'll look into this tmrw 👀

@bill-bateman bill-bateman Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeahhh this is an issue. I added a new commit to address this, since it took quite a bit of stuff. This got somewhat messy :/

tl;dr added a setup solver which reads the default sandbox's (patched) config, copies some info, and merges it with the scoring sandbox values, then stores that. the checker can then read that, put it in a tmp file, and call into sample_init.

(haven't yet tried running the new commit live on hawk fyi) - seems to work

Comment thread apn/sandbox.py
# but have a timeout so it doesn't hold forever
# and make the timeout longer than what the helm uninstall timeout itself is
with anyio.fail_after(
float(os.environ.get(_HELM_TIMEOUT_VAR, _HELM_TIMEOUT_DEFAULT_S)) * 2,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hawk sets INSPECT_HELM_TIMEOUT to 24h in the runner env (hawk/api/run.py), and helm install runs with --wait. Two consequences:

  1. A scoring pod that can't schedule (no node with 32Gi free, autoscaler stuck) blocks the scorer for up to 24h per attempt, times 3 attempts. In gated mode the scorer runs inside the solver on every submit, so that wait counts against the 72h working_limit. Before this PR the comparator was reserved at sample start, before any model spend; now a sample that has run for days can stall or fail at the very end depending on cluster capacity at that moment. There is no ResourceQuota on the sandbox namespace (checked the runner chart), so raw capacity is the constraint. Worth a scoring-specific bound around _install, and a decision about what a failed scoring install should do to the sample.
  2. This shield timeout becomes 48h. And the METR fork of k8s_sandbox that Hawk actually runs (rev ff1ef4a, 0.14.0) uses a separate INSPECT_HELM_UNINSTALL_TIMEOUT (default 600s) for uninstall, so deriving from INSPECT_HELM_TIMEOUT isn't the relevant variable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

so... just use INSPECT_HELM_UNINSTALL_TIMEOUT var instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

confirmed INSPECT_HELM_UNINSTALL_TIMEOUT is right https://github.com/METR/inspect_k8s_sandbox/blob/ff1ef4a6d845ed0abce87408e2ab2275d31c3687/src/k8s_sandbox/_helm.py#L632, I'll update that.

I don't think having a scoring-specific bound is useful?

And on failed scoring install, IIUC it would retry the sample from the top. Not sure how to get it to not do this, if we want it to not retry. 🤔

Comment thread pyproject.toml Outdated
Comment thread apn/task.py Outdated
Comment thread apn/checker.py

On docker the comparator is a long-lived service, resolved by name.

On k8s the comparator is a Helm release of its own, installed as

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Per-attempt pod churn in gated mode. Gated is the default and makes attempts effectively unlimited, so every submit now costs a helm install, scheduling, a pull of the Mathlib-sized comparator image if the node is cold, the check, and an uninstall. The 2.3s install in the test run was on a warm node. Worth measuring cold-start latency and checking how many submits per sample real runs make. If it's many, a per-sample release (installed on first check, torn down at sample end) is a middle ground, at the cost of some idle time again.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Trueeee

Warm vs cold latency
Looking at all the scoring calls, can separate time into 2 groups, which I'm assuming is cold vs warm

Extra scoring call when correct?
Hey weird, looks like the one task that actually succeeded called scoring twice - once for the "intermediate score" which passed, and then it scored again (2 logs like "Installed scoring sandbox release" - link). I guess it's double scoring. I had assumed it didn't re-score when it already passed once.

How many scores real runs make?

Looking at this run of gpt-6-astra on oeis-lite (100 samples) - note 43 / 100 got cutoff due to some sort of limit

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Looked a lot more into this for some cost/savings estimation.

Real evals sometimes run very very very very very many submission attempts. Sometimes legitimately ~100, but sometimes it enters a doom loop and gets into the 1,000s. If it thinks the task is impossible, it will keep looping the same exact submission over and over until it runs out of budget. This is a big problem!

If we are in a doom loop, constantly bringing up/down sandbox pods could be worse than just leaving one up! (You may have to wait for a new node to start up, the node may hang around after you are done with it)

I put more info in this doc (and gsheet) but tl;dr if we don't do anything about the doom loops then we probably don't want to merge this in. One thing we could do about doom loops is cache the last submission attempt and the response - if it is the exact same, then we can return the previous response.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Note that Tom's #60 stops doom loops outright.
My #64 caches the 1 latest scoring attempt but lets the doom loop continue. If we merge #60 then #64 might not be as useful. I want to update my cost estimation...

Comment thread apn/checker.py
Comment thread tests/test_sandbox.py
Comment thread apn/redteam.py
@bill-bateman
bill-bateman force-pushed the scoring-sandbox branch 2 times, most recently from da693ec to 84bfac9 Compare September 18, 2026 23:28
@bill-bateman

Copy link
Copy Markdown
Collaborator Author

Tested again with the hawk config patcher commit + score cache commit (606c2331b0bf40e62a2d456c8bf688e59f8281d8) - and you can see the scoring sandbox pods come up in the viewer UI now. Run link.

Screenshot from 2026-09-18 16-35-30

It is the one that is being created. The name is pretty ambiguous right now though, it looks the same. 🤔

This doesn't really test the score cache tho, since there is no doom loop here.

Previously we had 2 sandbox environments at all times. This meant the
32GiB scoring pod was sitting idle for most of the time, which costs
money.

Here, we spin up a scoring pod on-demand during the checker. Then we
execute the same set of commands to run the scoring. We manage the
scoring pods via K8sSandboxEnvironment calls directly - .sample_init and
.sample_cleanup.

Some notes:
- docker remains unchanged - brings up 2 containers at the start
- scoring pods do not count against max_sandboxes
- scoring pods are brought up within the same sandbox namespace, so the
  janitor should clean them up if anything goes pear shaped
- currently doesn't obey --no-sandbox-cleanup flag, i.e. all scoring
  sandbox pods get cleaned up
The on-demand scoring sandboxes bypass Hawk's sandbox patching.
Basically it sets a bunch of values that are useful, like pod labels for
tracking, karpenter do-not-disrupt, k8s nodeSelector, etc.

Here, grab relevant values off the default sandbox (for agent tool use)
and then merge them in with the scoring values.
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