CalCOFI.io CalCOFI.io Workflows

AI-readiness pilot: can off-the-shelf models answer CalCOFI questions?

24 questions × three conditions (closed-book, raw provider sources, the integrated release) × models and effort levels × 3 repeats

Author

CalCOFI (B. Best), preliminary data for the NSF 26-512 proposal (PI Watson-Parris)

Published

2026-10-30

Code
q   <- read_bench_questions()
ref <- if (file.exists(csv_ref)) read_csv(csv_ref, col_types = cols(reference = "c")) else {
  r <- bench_reference(q) |> select(id, tier, kind, tol_abs, tol_rel, reference)
  write_csv(r, csv_ref, na = ""); r }

# sessions: summarized from the transcripts when they are on this machine, else the tracked csv
runs <- if (dir.exists(path.expand(runs_root))) {
  r <- collect_bench_configs(runs_root, glue("^{params$run_prefix}")); write_csv(r, csv_runs, na = ""); r
} else read_csv(csv_runs, col_types = cols(answer = "c"))

scored <- score_bench_runs(runs, ref) |>
  mutate(config    = glue("{model} / {effort}"),
         condition = factor(condition, COND))

# accuracy per configuration x condition: mean over sessions, and the spread over the repeats
acc_rep <- scored |> group_by(config, model, effort, condition, rep) |>
  summarize(pct = 100 * mean(correct), cost = sum(cost_usd, na.rm = TRUE), .groups = "drop")
acc <- scored |> group_by(config, model, effort, condition) |>
  summarize(n = n(), pct = 100 * mean(correct),
            cost_usd = mean(cost_usd, na.rm = TRUE), minutes = mean(seconds, na.rm = TRUE) / 60, .groups = "drop") |>
  left_join(acc_rep |> group_by(config, condition) |>
              summarize(rep_min = min(pct), rep_max = max(pct), rep_sd = sd(pct), .groups = "drop"),
            by = c("config", "condition"))
n_q    <- nrow(q)
n_reps <- max(scored$rep)
n_cfg  <- n_distinct(scored$config)
rng    <- \(cnd, col = "pct") { x <- acc[[col]][acc$condition == cnd]; c(min(x), max(x)) }

Summary

Code
# raw vs release per configuration: accuracy gain, and the cut in cost and median time per answer
vs <- scored |> filter(condition != "closed") |>
  group_by(config, model, condition) |>
  summarize(pct = 100 * mean(correct), usd = mean(cost_usd, na.rm = TRUE), sec = median(seconds, na.rm = TRUE),
            turns = median(turns, na.rm = TRUE), .groups = "drop") |>
  pivot_wider(names_from = condition, values_from = c(pct, usd, sec, turns)) |>
  mutate(gain = pct_release - pct_raw, usd_cut = 100 * (1 - usd_release / usd_raw), sec_cut = 100 * (1 - sec_release / sec_raw))
r   <- \(cnd) { x <- round(rng(cnd)); if (x[1] == x[2]) glue("{x[1]}%") else glue("{x[1]}–{x[2]}%") }
pr  <- \(x) { x <- round(range(x)); if (x[1] == x[2]) glue("{x[1]}%") else glue("{x[1]}–{x[2]}%") }
big <- vs |> filter(model == "opus-5-5")
sml <- vs |> filter(model == "haiku-5-5")

# the references checked against the providers' own sources: questions every strongest-model raw session
# reproduced, with its own code, from ERDDAP / EDI / CalCOFI.org and never the release
val <- scored |> filter(condition == "raw", model == "opus-5-5") |>
  group_by(id) |> summarize(ok = sum(correct), n = n(), .groups = "drop")
n_val <- sum(val$ok == val$n)

What we did. Duncan Watson-Parris asked for a pilot of the benchmark proposed in Aim 2: about 20–30 questions about the data, put to off-the-shelf models, with performance summarized in a sentence or two as preliminary data. We put 24 questions to 5 model × effort configurations, 3 times each, under three conditions:

  • closed-book: no tools and no data, so the model answers from training alone.
  • raw: the providers’ public sources, as a user finds them today (ERDDAP, CalCOFI.org, EDI, NCEI, CCE-LTER). This is the pilot’s stand-in for the proposal’s “release 0”.
  • release: integrated release v2026.10.21 as a local DuckDB database, with no network.

What we found (Figure 1, Figure 2):

  1. Models cannot answer from training alone: 12–31% correct closed-book.
  2. The release made every model at least as accurate, and cheaper and faster. Accuracy was 76–96% from raw sources and 89–96% from the release. The release cut the cost per answer by 34–69% and the median time by 64–79%.
  3. The gain is largest for the smaller models. Haiku 5.5 went from 76% to 93%. The strongest model (Opus 5.5) was as accurate from raw files (96%) as from the release, but needed more steps, time and money to get there.
  4. The raw agents found a defect in the release: a cruise filed under two ship codes, so its bottle casts join to nothing (finding 4 under Raw vs. release disagreements). This is the release-to-release check Aim 2 proposes.
  5. The reference answers hold up against the providers’ own data. For 23 of the 24 questions, every Opus 5.5 raw session reproduced the reference independently: its own code, working from the providers’ sources, never the release (What is the ground truth?).

Sentence for the proposal

Code
words <- c("one", "two", "three", "four", "five", "six", "seven", "eight", "nine")
sentence <- glue(
  "A pilot (calcofi.io/workflows/bench_ai_readiness.html) shows the design works. We put {n_q} questions to ",
  "off-the-shelf models (three Claude models at {words[n_cfg]} model and effort settings, {words[n_reps]} runs each). They answered ",
  "{r('closed')} correctly from training alone, {r('raw')} when they had to find and download the providers' public ",
  "files themselves, and {r('release')} with the current release, which also cut the cost per answer by ",
  "{pr(vs$usd_cut)} and the median time by {pr(vs$sec_cut)}. The release helped the smallest model most ",
  "({round(sml$pct_raw)}% to {round(sml$pct_release)}%); the strongest agents matched it from raw files and, in doing ",
  "so, exposed a cruise-key defect in the release, which is the release-to-release check the benchmark is designed ",
  "to provide.")
cat("> ", sentence, "\n")

A pilot (calcofi.io/workflows/bench_ai_readiness.html) shows the design works. We put 24 questions to off-the-shelf models (three Claude models at five model and effort settings, three runs each). They answered 12–31% correctly from training alone, 76–96% when they had to find and download the providers’ public files themselves, and 89–96% with the current release, which also cut the cost per answer by 34–69% and the median time by 64–79%. The release helped the smallest model most (76% to 93%); the strongest agents matched it from raw files and, in doing so, exposed a cruise-key defect in the release, which is the release-to-release check the benchmark is designed to provide.

Code
writeLines(sentence, file.path(dir_out, glue("sentence_{run_tag}.txt")))
Code
COL <- c(closed = "#8e7cc3", raw = "#d95f02", release = "#1b6ca8")
cfg_order <- c("haiku-5-5 / default", "sonnet-5-5 / low", "sonnet-5-5 / default", "opus-5-5 / medium", "opus-5-5 / high")
by_tier <- bind_rows(
  scored |> mutate(tier = "all questions"),
  scored) |>
  group_by(config, condition, tier, rep) |> summarize(pct = 100 * mean(correct), .groups = "drop") |>
  group_by(config, condition, tier) |> summarize(mean = mean(pct), lo = min(pct), hi = max(pct), .groups = "drop") |>
  mutate(config = factor(config, cfg_order),
         tier   = factor(tier, c("all questions", "retrieval", "integration", "analysis", "unanswerable")))
off <- c(closed = 0.22, raw = 0, release = -0.22)
by_tier <- by_tier |> mutate(y = as.numeric(config) + off[as.character(condition)])
ggplot(by_tier, aes(mean, y, color = condition)) +
  geom_segment(aes(x = lo, xend = hi, yend = y), linewidth = 0.6, alpha = 0.45) +
  geom_point(size = 2.4) +
  facet_wrap(~tier, nrow = 1) +
  scale_color_manual(values = COL, name = NULL) +
  scale_x_continuous(limits = c(0, 100), breaks = c(0, 50, 100)) +
  scale_y_continuous(breaks = seq_along(cfg_order), labels = cfg_order, expand = expansion(add = 0.4)) +
  labs(x = "% correct", y = NULL) +
  theme_minimal(base_size = 11) +
  theme(legend.position = "top", panel.grid.minor = element_blank(), panel.grid.major.y = element_blank(),
        panel.spacing.x = unit(1, "lines"))
Figure 1: Accuracy by condition for each model and effort setting, overall and by question tier. Each point is the mean over 3 repeats; the bar through it spans the repeats’ lowest and highest scores.
Code
eff <- vs |>
  transmute(config = factor(config, cfg_order),
            raw_usd = usd_raw, release_usd = usd_release, raw_min = sec_raw / 60, release_min = sec_release / 60,
            raw_pct = pct_raw, release_pct = pct_release) |>
  pivot_longer(-config, names_to = c("condition", ".value"), names_sep = "_") |>
  pivot_longer(c(usd, min), names_to = "measure", values_to = "x") |>
  mutate(measure = factor(measure, c("usd", "min"), c("USD per answer (log scale)", "median minutes per answer (log scale)")))
arrows <- eff |> pivot_wider(names_from = condition, values_from = c(x, pct))
ggplot(eff, aes(x, pct)) +
  geom_segment(data = arrows, aes(x = x_raw, y = pct_raw, xend = x_release, yend = pct_release),
               color = "grey70", linewidth = 0.5, arrow = arrow(length = unit(0.12, "inches"), type = "closed")) +
  geom_point(aes(color = condition), size = 2.6) +
  ggrepel::geom_text_repel(data = eff |> filter(condition == "raw"), aes(label = config),
            size = 2.7, color = "grey35", min.segment.length = 0, seed = 1, box.padding = 0.4) +
  facet_wrap(~measure, scales = "free_x") +
  scale_x_log10(expand = expansion(mult = 0.15)) +
  scale_color_manual(values = COL[c("raw", "release")], name = NULL) +
  labs(x = NULL, y = "% correct") +
  theme_minimal(base_size = 11) +
  theme(legend.position = "top", panel.grid.minor = element_blank())
Figure 2: What the release changes for each configuration: accuracy against mean cost per answer (left) and median minutes per answer (right). Arrows run from raw sources to the release; up and to the left is better.

Why this pilot

The proposal’s Aim 2 scores the data, not the agent. Existing benchmarks for data-driven science hold the data fixed and compare agents: DiscoveryBench (Majumder et al., 2024), BLADE (Gu et al., 2024) and ScienceAgentBench (Chen et al., 2025). Aim 2 instead holds the agent fixed and asks how much each release improves what it can do. Five AI reviewers of the draft (punch-list row 2) said a small pilot would be the strongest preliminary result the text could add. This pilot reproduces the Aim 2 design at small scale:

  • the three tiers of questions, plus traps and unanswerable items
  • reference answers written as code
  • repeated runs on several pinned models and effort levels
  • a closed-book condition
  • a raw-source baseline

Design

Code
q |>
  left_join(ref |> select(id, reference), by = "id") |>
  mutate(reference = case_when(
    kind == "none"   ~ "NOT AVAILABLE",
    kind == "number" ~ as.character(signif(suppressWarnings(as.numeric(reference)), 5)),
    TRUE             ~ reference)) |>
  select(id, tier, question = text, reference) |>
  datatable(rownames = FALSE, options = list(pageLength = 30, dom = "t"))
Table 1: The pilot questions, with the reference answer computed in SQL against release v2026.10.21.

Questions. There are eight retrieval questions (one value from one dataset) and seven integration questions that join datasets at the same cruise and station:

  • bottle × ichthyoplankton
  • ZooScan × bottle
  • CTD × ichthyoplankton
  • the cruise and ship reference
  • a taxonomic join between the ichthyoplankton and mesopelagic-fish datasets

There are six analysis questions:

  • anomalies relative to a 1993–2013 station-by-month baseline
  • linear trends
  • a mean larval density where tows with no catch must count as zero. The ichthyoplankton records list only positive catches, so this one is a trap.
  • a correlation

Three questions cannot be answered from the data:

  • nitrate before 1959
  • bottle pH in 1985
  • CalBOBL bongo tows in 1972, before the bongo net replaced the ring net in 1977–78

The reference SQL for each question is in libs/bench_ai_readiness/questions.yml.

Reference answers are the question’s SQL run on the release. Where the raw sessions agreed with each other and disagreed with the release, we traced the difference to its source. In one case (finding 4 under Raw vs. release disagreements) the release was wrong, and the reference SQL now corrects for it. Elsewhere a legitimate difference between a provider’s file and the release still scores the raw answer wrong, so the pilot stays conservative for the raw condition.

Sessions. Each session is one headless Claude Code run (claude -p, version 2.1.314). It loads no CLAUDE.md, memory, skills or MCP servers, and runs inside Claude Code’s OS sandbox:

  • it can write only to its own folder
  • it cannot read the CalCOFI repositories, the other condition’s runs or, in the raw condition, the release copy
  • network access is limited to the providers’ domains in the raw condition, and is off in the release condition

Every tool call is checked afterwards for any reach past the session’s condition (Table 5). A session had 4 min (closed), 20 min (release) or 40 min (raw), and had to end with FINAL ANSWER: <value> or FINAL ANSWER: NOT AVAILABLE. Numbers are scored within the tolerance stated per question, and unanswerable items only by declining. The runner is libs/bench_ai_readiness/run_agents.sh and the scorer is libs/bench_ai_readiness/score.R.

Results

Code
heat <- scored |>
  group_by(id, tier, condition, config) |> summarize(ok = mean(correct), .groups = "drop") |>
  mutate(id = factor(id, rev(q$id)), config = factor(config, cfg_order),
         tier = factor(tier, c("retrieval", "integration", "analysis", "unanswerable")))
ggplot(heat, aes(config, id, fill = ok)) +
  geom_tile(color = "white", linewidth = 1) +
  facet_grid(tier ~ condition, scales = "free_y", space = "free_y") +
  scale_fill_gradient(low = "#eef3f8", high = "#1b6ca8", limits = c(0, 1), breaks = c(0, 0.5, 1),
                      labels = c("0 of 3", "", "3 of 3"), name = "correct") +
  labs(x = NULL, y = NULL) +
  theme_minimal(base_size = 10) +
  theme(axis.text.x = element_text(angle = 40, hjust = 1), panel.grid = element_blank(),
        strip.text.y = element_text(angle = 0), legend.position = "top", plot.margin = margin(5, 5, 5, 30))
Figure 3: Where each condition succeeds and fails: share of the 3 repeats answered correctly, per question (rows, grouped by tier) and configuration (columns, grouped by condition). R7 (dissolved iron) is behind a login at the provider; the release misses I1 because of the cruise-key defect in finding 4.
Code
scored |>
  group_by(config, condition, tier) |> summarize(pct = round(100 * mean(correct)), .groups = "drop") |>
  pivot_wider(names_from = tier, values_from = pct) |>
  left_join(acc |> transmute(config, condition, all = round(pct),
                             `repeats (min–max)` = glue("{round(rep_min)}–{round(rep_max)}"),
                             `USD/answer` = round(cost_usd, 2), `min/answer` = round(minutes, 1)),
            by = c("config", "condition")) |>
  arrange(config, condition) |>
  select(config, condition, retrieval, integration, analysis, unanswerable, everything()) |>
  kable()
Table 2: Accuracy (% of sessions correct) by configuration, condition and tier; spread across repeats; mean cost and time per answer.
config condition retrieval integration analysis unanswerable all repeats (min–max) USD/answer min/answer
haiku-5-5 / default closed 0 0 0 100 12 12–12 0.00 0.1
haiku-5-5 / default raw 83 57 94 67 76 75–79 0.03 3.1
haiku-5-5 / default release 96 86 100 89 93 92–96 0.01 0.5
opus-5-5 / high closed 38 5 11 89 28 25–29 0.04 0.2
opus-5-5 / high raw 88 100 100 100 96 96–96 0.33 2.1
opus-5-5 / high release 100 86 100 100 96 96–96 0.21 0.6
opus-5-5 / medium closed 38 0 28 89 31 29–33 0.03 0.1
opus-5-5 / medium raw 88 100 100 100 96 96–96 0.29 2.5
opus-5-5 / medium release 100 86 100 100 96 96–96 0.18 0.4
sonnet-5-5 / default closed 25 14 0 67 21 21–21 0.02 0.1
sonnet-5-5 / default raw 88 81 89 67 83 83–83 0.15 2.1
sonnet-5-5 / default release 96 86 100 100 94 92–96 0.09 0.3
sonnet-5-5 / low closed 25 5 0 67 18 17–21 0.01 0.1
sonnet-5-5 / low raw 88 81 83 78 83 79–88 0.13 1.5
sonnet-5-5 / low release 88 90 89 89 89 83–92 0.09 0.3
Code
scored |>
  group_by(id, condition) |>
  summarize(cell = glue("{sum(correct)}/{n()}"), .groups = "drop") |>
  pivot_wider(names_from = condition, values_from = cell) |>
  left_join(ref |> select(id, tier, reference), by = "id") |>
  mutate(id = factor(id, q$id)) |> arrange(id) |>
  select(id, tier, reference, any_of(COND)) |>
  kable()
Table 3: Sessions correct per question and condition, pooled over configurations and repeats.
id tier reference closed raw release
R1 retrieval 16.8866824925816 0/15 15/15 12/15
R2 retrieval 1959 6/15 15/15 15/15
R3 retrieval 40 12/15 15/15 15/15
R4 retrieval 0.758518413597734 0/15 15/15 13/15
R5 retrieval 2005.37778608826 12/15 14/15 15/15
R6 retrieval 5513 0/15 15/15 15/15
R7 retrieval 8.2 0/15 0/15 15/15
R8 retrieval 2634 0/15 15/15 15/15
I1 integration 14.4679428571429 3/15 15/15 1/15
I2 integration 1030 0/15 8/15 15/15
I3 integration NEW HORIZON 1/15 15/15 15/15
I4 integration 16.1822956521739 0/15 15/15 15/15
I5 integration 164 0/15 14/15 15/15
I6 integration 0.458421052631579 0/15 9/15 15/15
I7 integration 61 1/15 12/15 15/15
A1 analysis 2.3291495232111 3/15 14/15 15/15
A2 analysis 0.229209155233066 1/15 15/15 15/15
A3 analysis -0.160806237092192 2/15 15/15 15/15
A4 analysis 146.354720405078 0/15 10/15 14/15
A5 analysis -0.723257211959149 0/15 15/15 15/15
A6 analysis -0.0454855840524177 1/15 15/15 14/15
U1 unanswerable 0 15/15 15/15 15/15
U2 unanswerable 0 8/15 15/15 15/15
U3 unanswerable 0 14/15 7/15 13/15
Code
scored |>
  select(config, condition, id, rep, answer, reference, correct, cost_usd, seconds) |>
  arrange(id, condition, config, rep) |>
  datatable(rownames = FALSE, filter = "top", options = list(pageLength = 15, scrollX = TRUE))
Table 4: Every session’s answer (searchable).

Raw vs. release disagreements

Code
f_notes <- file.path(dir_out, glue("notes_{run_tag}.md"))
if (file.exists(f_notes)) cat(readLines(f_notes), sep = "\n")

Changes made during the run (2026-10-30), recorded so the protocol is honest about them.

  1. The raw-condition allowlist was widened, and every raw session was re-run. The first Sonnet 5.5 raw sessions were refused at hosts that serve the providers’ own data:

    • ccelter.ucsd.edu (CCE-LTER ZooScan)
    • library.ucsd.edu (UC San Diego Library Digital Collections, which holds the SIO mesopelagic fish data)
    • doi.org
    • DataONE
    • CeNCOOS ERDDAP

    Those failures measured the sandbox, not the data. All raw sessions from before the fix were deleted and run again with the allowlist now in run_agents.sh.

  2. The bottle quality rule was made explicit in 10 questions (R1, R4, I1, I4, I6, A1, A2, A3, A5, A6), and they were re-run for every configuration. The first wording said “unflagged”. In the bottle database, quality codes 0–5 and 7 are undocumented, 6 means “OK, taken from CTD”, and only 8 (suspect) and 9 (missing) mark bad values. The reference (qual_ok) excludes only 8 and 9, but some sessions read “unflagged” as “no code at all”. On I6 that gives 0.601 rather than 0.458 µmol/L, because the code-4 nitrate values are all 0. The questions now state the rule. The ambiguity is itself a finding for Aim 1: a bottle quality-code vocabulary in which most codes are undocumented costs an AI user (and a human one) a correct answer.

  3. U3 is scored as declared, which is strict. U3 asks how many anchovy larvae were counted in CB bongo tows in 1972. The bongo net replaced the ring net in 1977–78, so no CB tows exist that year. Raw sessions that found this, said so, and still answered “0” are scored wrong, because the pre-declared rule for unanswerable items is to decline. The by-question table shows how many U3 answers were “0” rather than NOT AVAILABLE.

  4. The raw sessions found a defect in the release, and the I1 and I2 references were corrected. On I1, every raw session agreed on 14.47 °C, and on I2 most raw sessions said 1030, against the release’s 14.36 °C and 1010. The raw agents were right. Release v2026.10.21 holds the July 2009 cruise twice, with the same dates under two keys:

    • 2009-07-31M4 “MCARTHUR”, derived from the bottle and picoplankton data.
    • 2009-07-33MH “MCARTHUR II”, the SWFSC reference used by ichthyo, CTD, ZooScan, PIC, seabirds and phytoplankton.

    No bottle cast from that cruise joins to anything else, and check_cruise_key_integrity() did not catch it. The reference SQL for I1 and I2 now maps the bottle key to the SWFSC key (commented in questions.yml), so it matches the providers’ own cruise and station. Release sessions that trusted the release’s key are scored against the corrected value:

    • I1 moves out of tolerance (14.36 vs 14.47, tolerance 0.1 °C).
    • I2 stays inside it (1010 vs 1030, tolerance 5%).

    This is the benchmark doing its job. A fix in the next release should raise the release condition’s score on exactly these items, which is the per-release comparison Aim 2 proposes.

  5. R7 (dissolved iron) is behind a login at the provider. No raw session answered R7. EDI refuses public reads of package knb-lter-cce.21 (“Public Access is not authorized to execute service method ‘listDataEntities’”, checked with curl on 2026-10-30), and the CCE-LTER Datazoo copy needs a guest login with a name and email, which the agents correctly declined to invent. The release serves the same table openly. One host on that path, gmn.lternet.edu, was outside the raw allowlist, but it is a DataONE replica of the same restricted package.

  6. The forbidden-access audit counts URLs and paths only. The first audit matched words anywhere in a tool call, so a Haiku session grepping a provider’s web page for the word “zenodo” counted as a reach. It now matches only the URLs and paths a call uses, plus loading a CalCOFI client package (bench_tool_forbidden() in score.R, with inline checks). After the change, one call remains. A Haiku raw session on I6 (rep 3) found calcofi.io/datasets/cce-lter_zooscan/ in web-search results and tried to curl its status code. The call was refused before it ran, and calcofi.io is outside the raw allowlist in any case. The session answered NOT AVAILABLE, so no release data reached a raw answer.

Audit

Code
scored |>
  group_by(config, condition) |>
  summarize(sessions = n(), completed = sum(completed), tool_calls = sum(n_tool_calls),
            forbidden_calls = sum(n_forbidden), sandbox_denials = sum(n_sandbox_deny), .groups = "drop") |>
  kable()
Table 5: Tool calls per configuration and condition, and calls that named a resource outside the condition (expected 0).
config condition sessions completed tool_calls forbidden_calls sandbox_denials
haiku-5-5 / default closed 72 72 0 0 0
haiku-5-5 / default raw 72 71 1269 1 82
haiku-5-5 / default release 72 72 652 0 0
opus-5-5 / high closed 72 72 0 0 0
opus-5-5 / high raw 72 72 997 0 26
opus-5-5 / high release 72 72 542 0 0
opus-5-5 / medium closed 72 72 0 0 0
opus-5-5 / medium raw 72 72 950 0 32
opus-5-5 / medium release 72 72 485 0 0
sonnet-5-5 / default closed 72 72 0 0 0
sonnet-5-5 / default raw 72 71 944 0 14
sonnet-5-5 / default release 72 72 470 0 0
sonnet-5-5 / low closed 72 72 0 0 0
sonnet-5-5 / low raw 72 72 885 0 18
sonnet-5-5 / low release 72 72 449 0 0

What is the ground truth?

Each question’s reference answer is SQL run on the integrated release (Table 1). That raises an obvious question: is the release being treated as the truth it is supposed to be tested against? Two checks guard against that.

  • Independent reproduction from the providers’ sources. In the raw condition, agents never see the release. Each one finds the providers’ files itself (mostly the NOAA ERDDAP copies of the bottle and ichthyoplankton databases, EDI and CCE-LTER) and writes its own code. For 23 of the 24 questions, every Opus 5.5 raw session landed on the reference within tolerance (Table 6). The one exception is R7 (dissolved iron), which the provider serves only behind a login.
  • Disagreements are traced, not scored away. On two integration questions the raw agents agreed with each other and not with the release. We traced the difference to a defect in the release: the July 2009 bottle casts are filed under a different ship code from every other dataset. The reference now corrects for it, and the release loses those points (finding 4 below).

So the reference is computed from the release, but checked against the providers’ own data by code the release’s authors did not write. The full benchmark goes further: a second scientist writes the reference code against the authoritative sources, as the proposal states.

Code
scored |> filter(condition == "raw") |>
  group_by(id) |>
  summarize(tier = first(tier), reference = first(reference),
            `all raw sessions` = glue("{sum(correct)}/{n()}"),
            `Opus 5.5 raw sessions` = glue("{sum(correct[model == 'opus-5-5'])}/{sum(model == 'opus-5-5')}"),
            .groups = "drop") |>
  mutate(reference = ifelse(is.na(suppressWarnings(as.numeric(reference))), reference,
                            as.character(signif(as.numeric(reference), 5))),
         id = factor(id, q$id)) |>
  arrange(id) |> kable()
Table 6: Raw sessions, which never see the release, that reproduced each reference answer from the providers’ own sources: all configurations, and the strongest model alone.
id tier reference all raw sessions Opus 5.5 raw sessions
R1 retrieval 16.887 15/15 6/6
R2 retrieval 1959 15/15 6/6
R3 retrieval 40 15/15 6/6
R4 retrieval 0.75852 15/15 6/6
R5 retrieval 2005.4 14/15 6/6
R6 retrieval 5513 15/15 6/6
R7 retrieval 8.2 0/15 0/6
R8 retrieval 2634 15/15 6/6
I1 integration 14.468 15/15 6/6
I2 integration 1030 8/15 6/6
I3 integration NEW HORIZON 15/15 6/6
I4 integration 16.182 15/15 6/6
I5 integration 164 14/15 6/6
I6 integration 0.45842 9/15 6/6
I7 integration 61 12/15 6/6
A1 analysis 2.3291 14/15 6/6
A2 analysis 0.22921 15/15 6/6
A3 analysis -0.16081 15/15 6/6
A4 analysis 146.35 10/15 6/6
A5 analysis -0.72326 15/15 6/6
A6 analysis -0.045486 15/15 6/6
U1 unanswerable 0 15/15 6/6
U2 unanswerable 0 15/15 6/6
U3 unanswerable 0 7/15 6/6

What the release adds, and what it does not

The providers already publish much of this data well. The bottle and ichthyoplankton databases are on NOAA ERDDAP, a service agents use fluently, so the retrieval questions are about equally answerable either way. What the release adds in this pilot is:

  • One schema and shared keys across datasets (cruise_key, site_key, taxon_key). An integration question becomes a join rather than a reconciliation of cruise codes, station names and taxon names across providers.
  • Provider quality codes in one column (measurement_qual, with qual_ok), so “exclude flagged values” is a filter rather than a hunt through each provider’s codebook.
  • Open access to data that is otherwise behind a login (R7).
  • Speed and cost. The same question takes fewer steps: a median of 7–10 turns against 10–16 from raw sources (Figure 2).

What the pilot does not test is the AI layer the proposal would build. The release condition gets a plain DuckDB file and a three-line README (libs/bench_ai_readiness/release_README.md). It has no MCP tools, no semantic metadata (Croissant, controlled vocabularies), no method register and no documentation of known traps. So these numbers measure the harmonized database alone, and are a floor for what an AI-ready release would score. The strongest raw agents also show that a well-run ERDDAP is already partly AI-ready. The benchmark is built to tell those contributions apart release by release.

Each session’s code

Every session’s work is kept as code. For each question, condition, configuration and repeat, code.md lists the commands and queries the agent ran, in order: shell commands, the scripts and SQL it wrote, and its searches and fetches. Downloaded data and tool output are left out. bench_session_code() in score.R extracts it from the session’s transcript.jsonl. Both files are in the archive bench_ai_runs_2026-10-30.tar.gz, at <config>/<condition>/<question>_r<repeat>/.

For example, here is how Opus 5.5 (high effort) answered I1 from the release:

Code
f_ex <- file.path(path.expand(runs_root), "2026-10-30_opus-5-5_high/release/I1_r1/code.md")
f_ex_tracked <- file.path(dir_out, "code_example_opus-5-5_high_release_I1_r1.md")
if (file.exists(f_ex)) file.copy(f_ex, f_ex_tracked, overwrite = TRUE)

[1] TRUE

Code
cat("<details><summary>code.md for opus-5-5 high / release / I1 rep 1</summary>\n\n")
code.md for opus-5-5 high / release / I1 rep 1
Code
cat(readLines(f_ex_tracked), sep = "\n")

release / I1_r1

Final answer: 14.364

Bash: Read README and list files

cat README.md; ls

Bash: List and describe tables

duckdb -readonly release.duckdb -c "show tables" ; for t in $(duckdb -readonly release.duckdb -csv -noheader -c "show tables"); do echo "== $t"; duckdb -readonly release.duckdb -c "describe $t"; done 2>&1 | head -400

Bash: Describe key tables in CSV

for t in obs_env obs_bio obs sample ichthyo_net cruise dataset measurement_type lookup sample_root taxon obs_attribute; do echo "== $t"; duckdb -readonly -csv release.duckdb -c "select column_name, column_type from (describe $t)"; done

Bash: Explore datasets, sardine taxon, temperature types, quality codes

cd /Users/bbest/_big/bench_ai_runs/2026-10-30_opus-5-5_high/release/I1_r1
duckdb -readonly release.duckdb -c "select dataset_key, dataset_name_short, tables from dataset" -c "select taxon_key, scientific_name, common_name from taxon where scientific_name ilike 'Sardinops%'" -c "select dataset_key, measurement_type, tow_type, life_stage, count(*) from obs_bio where taxon_key in (select taxon_key from taxon where scientific_name ilike 'Sardinops%') group by all order by all" -c "select measurement_type, description, units, provider, dataset from measurement_type where measurement_type ilike '%temp%'" -c "select * from lookup where lookup_type ilike '%qual%' or description ilike '%suspect%' limit 50"

Bash: Inspect bottle temperature and sardine larva records

cd /Users/bbest/_big/bench_ai_runs/2026-10-30_opus-5-5_high/release/I1_r1
duckdb -readonly release.duckdb -c "select measurement_qual, qual_ok, count(*) from obs_env where dataset_key='calcofi_bottle' and measurement_type='temperature' group by all order by all" -c "select * from obs_env where dataset_key='calcofi_bottle' and measurement_type='temperature' and year=2005 limit 3" -c "select * from obs_bio where dataset_key='swfsc_ichthyo' and tow_type='CB' and taxon_key='worms:217452' and life_stage='larva' and year=2005 limit 3" -c "select distinct lookup_type from lookup"

Bash: Compute matched occupations and mean 10 m temperature

cd /Users/bbest/_big/bench_ai_runs/2026-10-30_opus-5-5_high/release/I1_r1
duckdb -readonly release.duckdb -c "
create temp table occ as select distinct cruise_key, site_key from obs_bio where dataset_key='swfsc_ichthyo' and tow_type='CB' and taxon_key='worms:217452' and life_stage='larva' and value>0 and year between 2000 and 2009;
select count(*) from occ;
create temp table t10 as select cruise_key, site_key, value, measurement_qual, depth_min_m, sample_key from obs_env where dataset_key='calcofi_bottle' and measurement_type='temperature' and depth_min_m=10;
select count(*) n_occ_matched, sum(n) nrows, sum(case when n>1 then 1 else 0 end) multi from (select o.cruise_key,o.site_key,count(*) n from occ o join t10 t using(cruise_key,site_key) group by all);
select measurement_qual, count(*) from occ o join t10 t using(cruise_key,site_key) group by all;
select count(*) n, avg(v), avg(nr) from (select cruise_key, site_key, avg(value) v, count(*) nr from occ o join t10 t using(cruise_key,site_key) where coalesce(measurement_qual,'') not in ('8','9') and value is not null group by all);
"

Bash: Diagnose unmatched occupations

cd /Users/bbest/_big/bench_ai_runs/2026-10-30_opus-5-5_high/release/I1_r1
duckdb -readonly release.duckdb -c "
create temp table occ as select distinct cruise_key, site_key from obs_bio where dataset_key='swfsc_ichthyo' and tow_type='CB' and taxon_key='worms:217452' and life_stage='larva' and value>0 and year between 2000 and 2009;
create temp table b as select distinct cruise_key, site_key from obs_env where dataset_key='calcofi_bottle' and measurement_type='temperature';
select count(*) filter (where b.site_key is not null) with_any_bottle from occ left join b using(cruise_key,site_key);
select o.cruise_key, count(*) from occ o left join b using(cruise_key,site_key) where b.site_key is null group by all order by 1 limit 40;
select o.cruise_key,o.site_key, list(t.depth_min_m order by t.depth_min_m)[1:8] from occ o join b using(cruise_key,site_key) join obs_env t on t.dataset_key='calcofi_bottle' and t.measurement_type='temperature' and t.cruise_key=o.cruise_key and t.site_key=o.site_key group by all having count(*) filter (where t.depth_min_m=10)=0 limit 10;
"

Bash: Check unmatched occupation station IDs

cd /Users/bbest/_big/bench_ai_runs/2026-10-30_opus-5-5_high/release/I1_r1
duckdb -readonly release.duckdb -c "
create temp table occ as select distinct cruise_key, site_key from obs_bio where dataset_key='swfsc_ichthyo' and tow_type='CB' and taxon_key='worms:217452' and life_stage='larva' and value>0 and year between 2000 and 2009;
create temp table b as select distinct cruise_key, site_key from obs_env where dataset_key='calcofi_bottle' and measurement_type='temperature';
select o.cruise_key, list(o.site_key)[1:6] unmatched, (select count(distinct site_key) from b where b.cruise_key=o.cruise_key) n_btl_sites from occ o where not exists (select 1 from b where b.cruise_key=o.cruise_key and b.site_key=o.site_key) group by all order by 1;
"
Code
cat("\n</details>\n")

Limitations

  • 24 questions is a pilot of the method, not a measurement of the release. The proposal’s benchmark has about 250 questions, an open-weights model, human baselines and a held-out set.
  • The models are all from one family (Claude). Closed-book scores also depend on what each model saw in training.
  • The reference answers come from the release under test, so a release defect shared by the reference and the release condition goes unnoticed unless the raw sessions disagree. They caught one here (finding 4); others may remain. In the full benchmark, a second scientist writes the reference code against the authoritative sources.
  • The questions were written by someone who knows the release, and most of them can be answered from the providers’ well-documented ERDDAP services. This favours neither condition by design, but it is not a sample of the questions users ask.
  • The release condition gets a plain DuckDB file with a three-line README. It has none of the AI layer the proposal would build: no MCP tools, no semantic metadata, no method register.

Reproduce

# run in bash (the loop relies on word splitting)
# 1. local copy of the release (about 600 MB of parquet, plus views)
libs/bench_ai_readiness/build_release_db.sh v2026.10.21 ~/_big/bench_ai_release
# 2. sessions per configuration: questions x conditions x 3 repeats
for cfg in "sonnet-5-5 default" "sonnet-5-5 low" "opus-5-5 medium" "opus-5-5 high" "haiku-5-5 default"; do
  set -- $cfg
  libs/bench_ai_readiness/run_agents.sh "2026-10-30_$1_$2" "release raw closed" 3 6 "claude-$1" "$2"
done
# 3. each session's code.md, extracted from its transcript
Rscript -e 'source("libs/bench_ai_readiness/score.R"); for (d in dirname(list.files("~/_big/bench_ai_runs", "transcript.jsonl", recursive = TRUE, full.names = TRUE))) bench_session_code(d)'
# 4. this notebook: reference answers, scoring, figures, tables
quarto render bench_ai_readiness.qmd

The transcripts of every session are archived as bench_ai_runs_2026-10-30.tar.gz beside the proposal drafts. The tracked summary is data/bench_ai_readiness/runs_2026-10-30.csv.