Skip to content

Commit 6d05bb0

Browse files
turbomamhesspnnlclaude
authored
Env-triad eval: automation on top of Olivia's branch (#69)
* begin eval * create test cases * cases were wrong * update expected value fields * add multiple models to the cases * Plumbing for env-triad eval: rename + gitignore + just targets Mechanical changes to align with sibling-eval conventions (sampledata, ebs, field-guidance). No behavior change to the generator or suite content — those are follow-ups per #55. - Rename `create_test_cases.py` -> `generate_suite.py` to match other datasets' generator names. Update `OUTPUT_YAML` constant to `env-triad-suite.yaml` (drop `-test-` infix). - Remove the committed 48 MB `env-triad-test-suite.yaml` — other datasets never commit the generated YAML; it's regenerated via `just generate-*`. - Gitignore all four `datasets/*/{name}-suite.yaml` paths so a regenerated suite doesn't accidentally end up committed. - Add `just` targets mirroring the sibling pattern: - `generate-env-triad` — run the generator - `run-env-triad` — run the suite via `llm_matrix` - `eval-env-triad` — clean + generate + run (matches `eval-sampledata`, `eval-ebs`) - `clean-env-triad-outputs` — remove the output dir + sqlite - Hook `eval-env-triad` into `clean-outputs` and `generate-all`. Follow-ups tracked in #55: - Move MIxS schema context from per-case `input` into template `system` prompt - Build ideals as `label [CURIE]` instead of bare `ENVO_...` - Pull from multiple studies for biome differentiation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Correctness pass on env-triad generator: dynamic schema + null safety Rewrites generate_suite.py. Shrinks from 805 lines to ~215. Behavior changes: - **Schema context is now dynamically sourced.** Previously the system prompt embedded a 700-line hardcoded string that was a frozen snapshot of the production suggestor's env-triad schema context. Any upstream MIxS change would silently drift from this generator. Now the system prompt calls SchemaContextBuilder at generation time and filters slots to the three env-triad fields per interface. Reproduces the logic of SchemaContextBuilder.format_env_triad_context (present in newer suggestor versions but not the pinned v1.1) using v1.1 primitives (get_interface_schema + format_slot). Once the suggestor dep is bumped past PR #79's release, this function can collapse to a single call. - **Ideal construction is null-safe.** Previously `biosample.get("env_broad_scale").get("term").get("name")` raised AttributeError on any biosample missing an env-triad field. Now each term value goes through `_term_to_label_curie` which returns None on missing/malformed input, and `make_ideal` returns None for a case that can't produce a scorable ideal. The main loop skips such cases and reports a count. No more silent crashes mid-generation. - **--study-id CLI flag.** Was hardcoded to Bioscales; now defaults to it but accepts any NMDC study ID. Sets up the multi-study scope work (#55) to come. Format of ideals (`label [CURIE]`) was already correct on this branch — that's preserved. Module docstring updated to say "public API" instead of "MongoDB" (inaccurate; StudySearch hits the NMDC REST API). Plumbing from the prior commit (rename, just targets, gitignore) remains unchanged; this commit only touches the generator. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Scope pass on env-triad generator: multi-study support Adds the infrastructure for pulling biosamples from multiple NMDC studies, setting up the biome-diversity work #55 calls for. - New config file: datasets/env-triad-prediction/studies.yaml lists the studies to include. Current list is a starting point (just Bioscales) with structure in place to add more — an `expected_biomes` field lets a future reviewer sanity-check that the selection covers aquatic, host-associated, built-environment, sediment, etc. - Generator now iterates over a list of study IDs. CLI accepts --study-id <id> repeated, or reads the curated list from studies.yaml by default. `--studies-yaml <path>` allows a different config file. - Each case gets the study ID as an extra tag (alongside the existing value_prediction tag), so result analysis can filter/group by study without re-deriving it from the input text. - Summary reports both the overall and per-study kept/skipped counts. The actual expansion of studies to get biome diversity is deliberately not in this commit — picking which NMDC studies to include is a domain-expert judgment that belongs to Olivia, not me. The infrastructure to add them is now trivial: extend studies.yaml. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add `just pilot-env-triad` for tiny smoke-test runs Running the full eval is 416 cases x 5 models (~\$10+). Before committing to that, it's useful to prove the generate -> run pipeline works end-to-end on a handful of cases with one cheap model. Previously that required hand-editing the generated YAML, which is fragile. - `generate_suite.py` gains two flags: - `--max-cases N`: cap the number of cases after generation - `--models NAME[,NAME...]`: override the model list from models.yaml - `just pilot-env-triad` chains clean + small generate + run. Defaults to 3 cases x gpt-4o-mini (a fraction of a cent). Override with positional args: `just pilot-env-triad 10 gpt-4o`. The generate flags stand on their own — useful outside the pilot target for any time you want a bounded generator output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Escape braces in env-triad system prompt for llm-matrix str.format llm-matrix calls template.system.format(**template_params) on the system prompt at inference time (see llm_matrix.aimodel.AIModel.prompt line 67). Any literal { / } in the system string — e.g. the JSON schema example and the regex patterns inside the MIxS schema context like {7,8} — was treated as a {placeholder} and raised KeyError during the first live pilot: Error during eval: '\n "metadata_fields"' Fix: double literal braces ({{ / }}) so str.format emits the literal characters instead of trying to look them up as keys. Pilot run after the fix succeeds end-to-end: 3/3 calls complete, outputs captured, tokens logged. Scoring misses are a separate (format-level) issue tracked under #61 — not a pipeline break. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Readable summary + per-field columns for env-triad evals Two display/reporting changes, both in run_suite.py: Short labels in the console summary. Previously the per-case, per- category, and misses sections all dumped the full case_ideal JSON — a 300+ char blob per row. Adding _try_parse_env_triad() to extract env_broad_scale / env_local_scale / env_medium values, and _short_label() to format them as "broad | local | medium", makes the output readable at any scale. Falls back to the first 80 chars for non-env-triad ideals — non-env-triad evals (ebs, sampledata) are unaffected. Also widens the "got" display from 50 to 200 chars. Per-field columns in results.tsv. Added expected_{broad,local,medium}, got_{broad,local,medium}, and {broad,local,medium}_match booleans. Lets downstream analysis pivot on per-field accuracy (which field is hardest? which models nail medium?) without re-parsing JSON. Columns are all None for evals whose ideal isn't env-triad-shaped. Pilot run after the changes: output fits on one line per row, per- category grouping uses short labels, TSV has 9 new columns. Verified with 3-case gpt-4o-mini pilot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address Copilot review on env-triad PR Four valid catches: - **Greedy JSON regex.** The non-greedy `\{.*?\}` theoretically stops at the first inner `}`; env-triad JSON has nested `{}` per field in metadata_fields. Backtracking saved us in the pilot, but a greedy match anchored by the closing fence is safer. Also catches edge cases with multiple fenced blocks in one response. - **Exception safety in _try_parse_env_triad.** Docstring said "never raises" but could raise AttributeError/TypeError if the parsed JSON is a list, or if metadata_fields items aren't dicts. Added isinstance guards so the helper is actually exception-safe and only accesses `.get()` on dicts. - **`*_match` was always True for all-None rows.** For non-env-triad evals (or responses that failed to parse), both expected_broad and got_broad are None, and `None == None` evaluates True — misleading "all match" signal. Match columns now return None when either side is None; False only when the sides differ with at least one value. - **`clean-suites` missed field-guidance suite YAML.** I added env-triad to the list but the existing field-guidance YAML — which is also gitignored and regenerated — was never cleaned. Adding it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Document how to use CBORG / AI Studio in eval suites Two workflow gaps Olivia will hit once she moves beyond the default model list in `models.yaml`: - **CBORG in suite evals.** Setting CBORG_API_KEY in the secrets file only covers verify-auth / probe-tiers (which use the openai SDK directly). Suite evals route through llm-matrix -> llm plugins, so they need aliases defined in `~/.config/io.datasette.llm/extra-openai-models.yaml` plus a separate `llm keys set cborg`. Now documented with the exact steps + pointer to `just probe-tiers --list-cborg-models` for the ~200-model catalog. - **AI Studio expansion.** `llm-gemini` supports the full AI Studio catalog, not just the entries in `models.yaml`. Added a short section noting you can drop any `gemini/<name>` into a pilot command. Also flags #71 as a known limitation: the CBORG key currently has to live in both the secrets file and the llm keystore. Low-priority follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add --env flag to generator for dev API fallback When api.microbiomedata.org returns 502, the dev API (api-dev.microbiomedata.org) is often still up. The StudySearch class already supports env="dev" — this just exposes it at the CLI level. - generate_suite.py: --env {prod,dev} flag, default prod. Prints a note when dev is used so the source is visible in run logs. - justfile: threads env param through generate-env-triad and pilot-env-triad with default "prod". Usage: just pilot-env-triad 50 gpt-4o-mini dev just generate-env-triad dev Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Document dev API fallback in justfile, docstring, and auth.md - justfile: expand pilot-env-triad and generate-env-triad comments to mention the env param and show an example with dev - generate_suite.py: add dev-API and multi-model examples to the Usage:: docstring - docs/auth.md: add troubleshooting entry for NMDC 502s with the exact fallback commands Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Make run_suite robust to scorer parse errors; add --scorer-model flag Problem: llm-matrix's simple_question metric makes a second LLM call to score each response. Some models (e.g. cborg/claude-sonnet-4-6) return a verbose explanation like "The score is 0.33 because..." instead of starting with the number. The regex `r"(\d+(\.\d+)?)"` applied with `.match()` fails on prose-first responses and raises ValueError("Could not parse score from ..."), crashing the entire run at the failing call. Two changes: 1. **Continue on score-parse errors.** Catch ValueError in the per-case iteration loop and in a fallback except block below it. Record score=None for the affected results, emit a warning, and continue. The TSV still gets written with all results collected so far. 2. **--scorer-model flag (+ LLM_SCORER_MODEL env var).** Exposes LLMRunnerConfig.evaluation_model_name so users can pin the scorer to a known-reliable model (e.g. gpt-4o-mini) when the default model behaves badly as a judge. Also printed in the run header so it appears in logs. Together these make multi-model runs resilient to scorer flakiness on any single model — the run completes and marks affected rows as score=None rather than aborting. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Wire --scorer-model through just run target Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix run_suite to continue on scorer parse errors with full logging Replace the broad try/except block with a while/next() loop that catches ValueError (scorer parse failure) per iteration. When the scorer returns prose instead of a leading number: - The exception text (which contains the scorer's full response) is printed to stderr immediately - A placeholder token_data entry is added to keep indexing aligned - The loop continues from the next case - score_parse_failures is incremented For regular results, evaluation_message (the scorer's response) is already in the TSV via results_to_dataframe, so successful scorer calls are fully logged without any extra work. The end-of-run note now accurately describes what happens to failed cases (the result is lost — not just scored as None — because the exception interrupted the iterator before the result was yielded), and points users to --scorer-model / LLM_SCORER_MODEL as a preventative measure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(run_suite): handle NaN case_ideal in _try_parse_env_triad When a scorer parse error drops a result mid-run, the DataFrame that feeds the summary has NaN (float) in case_ideal for that row. The old guard (`if not text`) didn't catch floats — re.search then crashed the whole run with TypeError. Tighten the guard to `isinstance(text, str)` and add a regression test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(run_suite): drop NaN rows before summary; harden _short_label Earlier NaN fix in _try_parse_env_triad only covered one code path. _short_label also touched case_ideal/response_text without guarding and crashed mid-summary on lost rows. Two-layer fix: - Drop rows with NaN case_ideal at the top of _print_summary (these are results lost to scorer parse errors — nothing in the summary can be computed from them, they don't belong in ranking or category pivots). - Mirror the isinstance guard from _try_parse_env_triad in _short_label as defense-in-depth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(env-triad): drop simple_question metric — unblocks long runs Root cause of today's 53-of-1200 early termination: llm-matrix's runner generator raises ValueError when simple_question's scorer model returns prose instead of a bare float. Once the generator has raised, subsequent next() calls return StopIteration — the continue-on-error logic in run_suite.py can't re-enter the closed generator. Result: one flaky scorer response kills the whole run. The env-triad suite doesn't need simple_question anyway. We override r.score with _env_triad_score (direct per-field match) in run_suite.py, so every simple_question call was making a second LLM call per result whose score we discard — strictly cost and failure surface, no signal. Other suites (sampledata, ebs, field-guidance) still use simple_question because they rely on its score. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: olivia.hess <olivia.hess@pnnl.gov> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9efeb80 commit 6d05bb0

9 files changed

Lines changed: 988 additions & 115 deletions

File tree

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
*-output/
1010
*.db
1111

12+
# Generated suite YAMLs (regenerate on demand via `just generate-*`)
13+
datasets/submission-metadata-prediction/sampledata-suite.yaml
14+
datasets/ebs-prediction/ebs-suite.yaml
15+
datasets/field-guidance/field-guidance-suite.yaml
16+
datasets/env-triad-prediction/env-triad-suite.yaml
17+
1218
# Pipeline eval results — committed for reproducibility.
1319
# Individual result YAMLs are timestamped and never overwritten.
1420

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
#!/usr/bin/env python3
2+
"""Generate an llm-matrix suite YAML for env triad prediction.
3+
4+
Pulls biosamples from NMDC studies via the public API and builds a suite
5+
where each case asks the model to predict ``env_broad_scale``,
6+
``env_local_scale``, and ``env_medium`` for one biosample. Ideals are
7+
built as ``label [CURIE]`` from the curated ``term`` data on the biosample
8+
(e.g. ``"terrestrial biome [ENVO:00000446]"``).
9+
10+
The MIxS schema context — the per-interface definitions of the three env
11+
triad slots — is included once in the template's ``system`` prompt, not
12+
per case. It is built dynamically via the production suggestor's
13+
``SchemaContextBuilder`` so it stays in sync with upstream MIxS changes.
14+
15+
Biosamples missing any of the three env-triad term values are skipped
16+
(they can't produce a scorable ideal). The skipped count is reported.
17+
18+
Usage::
19+
20+
uv run python datasets/env-triad-prediction/generate_suite.py
21+
uv run python datasets/env-triad-prediction/generate_suite.py --study-id nmdc:sty-...
22+
uv run python datasets/env-triad-prediction/generate_suite.py \
23+
--max-cases 50 --models gpt-4o-mini,cborg/claude-sonnet-4-6
24+
uv run python datasets/env-triad-prediction/generate_suite.py --env dev # fallback when prod API returns 502
25+
26+
Or via just::
27+
28+
just generate-env-triad # full suite, prod API
29+
just generate-env-triad dev # full suite, dev API (api-dev.microbiomedata.org)
30+
just pilot-env-triad 50 "gpt-4o-mini,cborg/claude-sonnet-4-6" dev
31+
"""
32+
33+
import argparse
34+
import json
35+
from pathlib import Path
36+
from typing import Any
37+
38+
import yaml
39+
from nmdc_api_utilities.study_search import StudySearch
40+
from nmdc_metadata_suggestor_ai_tool.schema_context import SchemaContextBuilder, format_slot
41+
42+
HERE = Path(__file__).parent
43+
MODELS_YAML = HERE.parent / "models.yaml"
44+
OUTPUT_YAML = HERE / "env-triad-suite.yaml"
45+
STUDIES_YAML = HERE / "studies.yaml"
46+
47+
ENV_TRIAD_SLOTS = ["env_broad_scale", "env_local_scale", "env_medium"]
48+
49+
SYSTEM_PROMPT_HEAD = """Use the provided information to suggest values for the
50+
following three metadata fields: env_broad_scale, env_local_scale, and env_medium.
51+
- env_broad_scale: the broad environmental category of the sample (e.g. aquatic, terrestrial, host-associated).
52+
- env_local_scale: the more specific local environment of the sample (e.g. sediment, water column, rhizosphere).
53+
- env_medium: the medium in which the sample was collected (e.g. soil, water, air).
54+
55+
Values must be chosen from the enumerations in the NMDC schema and should be
56+
based on the content of the information provided. The value format is
57+
``label [CURIE]`` (e.g. ``terrestrial biome [ENVO:00000446]``).
58+
59+
Output schema:
60+
```json
61+
{
62+
"metadata_fields": [
63+
{"field_name": "env_broad_scale", "reason": "...", "value": ""},
64+
{"field_name": "env_local_scale", "reason": "...", "value": ""},
65+
{"field_name": "env_medium", "reason": "...", "value": ""}
66+
]
67+
}
68+
```
69+
70+
Schema context to choose from:
71+
"""
72+
73+
74+
def build_env_triad_schema_context() -> str:
75+
"""Build the env-triad-specific MIxS schema context string.
76+
77+
Replicates ``SchemaContextBuilder.format_env_triad_context`` (present
78+
in newer suggestor versions) using v1.1-compatible primitives. Once
79+
the suggestor dep is bumped to a version shipping
80+
``format_env_triad_context``, this can collapse to a single call.
81+
"""
82+
builder = SchemaContextBuilder()
83+
sections: list[str] = []
84+
for interface_name in builder.list_interfaces():
85+
schema = builder.get_interface_schema(interface_name)
86+
triad_slots = [s for s in schema.slots if s.name in ENV_TRIAD_SLOTS]
87+
if not triad_slots:
88+
continue
89+
lines = [f"# {schema.class_name} - Selected Fields"]
90+
for slot in triad_slots:
91+
lines.extend(format_slot(slot))
92+
sections.append("\n".join(lines))
93+
return "\n\n---\n\n".join(sections)
94+
95+
96+
def build_system_prompt() -> str:
97+
"""Build the full system prompt, with braces escaped for ``str.format``.
98+
99+
llm-matrix runs ``template.system.format(**template_params)`` on the
100+
system string at inference time (see ``llm_matrix.aimodel.AIModel.prompt``).
101+
Any literal ``{`` / ``}`` in the prompt — the JSON schema example,
102+
regex patterns in the MIxS schema context like ``{7,8}`` — would be
103+
treated as format placeholders and fail with KeyError. Doubling them
104+
makes ``str.format`` emit the literal characters.
105+
"""
106+
raw = SYSTEM_PROMPT_HEAD + build_env_triad_schema_context()
107+
return raw.replace("{", "{{").replace("}", "}}")
108+
109+
110+
def load_models() -> list[str]:
111+
with open(MODELS_YAML) as f:
112+
return yaml.safe_load(f)["models"]
113+
114+
115+
def load_study_ids(studies_yaml: Path) -> list[str]:
116+
"""Read the curated list of study IDs from studies.yaml."""
117+
with open(studies_yaml) as f:
118+
config = yaml.safe_load(f)
119+
return [entry["id"] for entry in config.get("studies", [])]
120+
121+
122+
def get_study_with_biosamples(study_id: str, env: str = "prod") -> dict[str, Any]:
123+
"""Fetch one study and its linked biosamples from the NMDC API.
124+
125+
``env`` is passed through to ``StudySearch`` — use ``"dev"`` to hit
126+
``api-dev.microbiomedata.org`` when the prod API is unavailable.
127+
"""
128+
search = StudySearch(env=env)
129+
study = search.get_record_by_id(collection_id=study_id)
130+
biosamples = search.get_linked_instances(
131+
ids=[study_id],
132+
types="nmdc:Biosample",
133+
hydrate=True,
134+
max_page_size=1999,
135+
)
136+
return {"study": study, "biosamples": biosamples}
137+
138+
139+
def format_prompt(biosample: dict, study: dict) -> str:
140+
"""Build the per-case prompt from biosample + study metadata.
141+
142+
Schema context is intentionally NOT here — that lives in the template's
143+
system prompt so it's sent once per run, not once per case.
144+
"""
145+
clean = biosample.copy()
146+
# Strip the ground-truth env triad fields so the model can't cheat
147+
for slot in ENV_TRIAD_SLOTS:
148+
clean.pop(slot, None)
149+
return (
150+
f"--- Study Metadata ---{study}\n--- Biosample Metadata ---{clean}\nSuggest env triad values for the biosample"
151+
)
152+
153+
154+
def _term_to_label_curie(term_value: dict | None) -> str | None:
155+
"""Format a TermValue dict as ``'label [CURIE]'``.
156+
157+
Returns ``None`` if the term data is missing or incomplete (biosample
158+
field absent, ``term`` missing, or either ``name``/``id`` empty).
159+
Callers should skip cases where any env-triad term is ``None`` rather
160+
than emit an ideal the scorer can't match.
161+
"""
162+
if not term_value:
163+
return None
164+
term = term_value.get("term")
165+
if not term:
166+
return None
167+
name = term.get("name")
168+
term_id = term.get("id")
169+
if not name or not term_id:
170+
return None
171+
return f"{name} [{term_id}]"
172+
173+
174+
def make_ideal(biosample: dict) -> str | None:
175+
"""Build the ideal answer as JSON, or ``None`` if any env-triad term
176+
is missing/malformed on the biosample."""
177+
values: dict[str, str | None] = {slot: _term_to_label_curie(biosample.get(slot)) for slot in ENV_TRIAD_SLOTS}
178+
if any(v is None for v in values.values()):
179+
return None
180+
fields = [{"field_name": slot, "reason": "", "value": values[slot]} for slot in ENV_TRIAD_SLOTS]
181+
return json.dumps({"metadata_fields": fields})
182+
183+
184+
def main() -> None:
185+
parser = argparse.ArgumentParser(description="Generate env-triad llm-matrix suite")
186+
parser.add_argument("--output", type=Path, default=OUTPUT_YAML)
187+
parser.add_argument(
188+
"--study-id",
189+
type=str,
190+
action="append",
191+
default=None,
192+
help=(
193+
"NMDC study ID. May be repeated. If omitted, reads the curated "
194+
f"list from {STUDIES_YAML.relative_to(HERE.parent.parent)}."
195+
),
196+
)
197+
parser.add_argument(
198+
"--studies-yaml",
199+
type=Path,
200+
default=STUDIES_YAML,
201+
help="Alternate studies.yaml config path (ignored if --study-id is given).",
202+
)
203+
parser.add_argument(
204+
"--max-cases",
205+
type=int,
206+
default=None,
207+
help="Cap the number of cases for quick pilot runs. None = all cases.",
208+
)
209+
parser.add_argument(
210+
"--models",
211+
type=str,
212+
default=None,
213+
help=(
214+
"Comma-separated model names to override the list from models.yaml "
215+
"(e.g. 'gpt-4o-mini' or 'gpt-4o-mini,gemini/gemini-2.5-flash'). "
216+
"Useful with --max-cases for cheap pilot runs."
217+
),
218+
)
219+
parser.add_argument(
220+
"--env",
221+
choices=["prod", "dev"],
222+
default="prod",
223+
help=(
224+
"NMDC API environment to fetch biosamples from. Use 'dev' "
225+
"(api-dev.microbiomedata.org) when the prod API is unavailable."
226+
),
227+
)
228+
args = parser.parse_args()
229+
230+
study_ids: list[str] = args.study_id if args.study_id else load_study_ids(args.studies_yaml)
231+
models = [m.strip() for m in args.models.split(",")] if args.models else load_models()
232+
233+
if args.env != "prod":
234+
print(f"Note: using NMDC {args.env} API (api-{args.env}.microbiomedata.org)")
235+
236+
cases: list[dict[str, Any]] = []
237+
per_study_counts: list[tuple[str, int, int]] = [] # (id, kept, skipped)
238+
239+
for study_id in study_ids:
240+
data = get_study_with_biosamples(study_id, env=args.env)
241+
study = data["study"]
242+
kept = 0
243+
skipped = 0
244+
for biosample in data["biosamples"]:
245+
ideal = make_ideal(biosample)
246+
if ideal is None:
247+
skipped += 1
248+
continue
249+
cases.append(
250+
{
251+
"input": format_prompt(biosample, study),
252+
"ideal": ideal,
253+
"tags": ["value_prediction", study_id],
254+
}
255+
)
256+
kept += 1
257+
per_study_counts.append((study_id, kept, skipped))
258+
259+
if args.max_cases is not None and len(cases) > args.max_cases:
260+
cases = cases[: args.max_cases]
261+
262+
suite = {
263+
"name": "Env Triad Prediction Test Suite",
264+
"template": "env_triad_prediction",
265+
"templates": {
266+
"env_triad_prediction": {
267+
"system": build_system_prompt(),
268+
"prompt": "{input}",
269+
# No `metrics:` — scoring is done by _env_triad_score in
270+
# run_suite.py, which compares ideal/response per field
271+
# exactly. Using simple_question here would just add a
272+
# second LLM call per result whose score we discard, and
273+
# its parse errors permanently kill llm-matrix's iterator.
274+
}
275+
},
276+
"matrix": {
277+
"hyperparameters": {
278+
"model": models,
279+
"temperature": [0.0],
280+
}
281+
},
282+
"cases": cases,
283+
}
284+
285+
with open(args.output, "w") as f:
286+
yaml.dump(suite, f, default_flow_style=False, sort_keys=False, width=120, allow_unicode=True)
287+
288+
total_kept = sum(k for _, k, _ in per_study_counts)
289+
total_skipped = sum(s for _, _, s in per_study_counts)
290+
print(f"Generated {total_kept} cases ({total_skipped} skipped) x {len(models)} models -> {args.output}")
291+
for study_id, kept, skipped in per_study_counts:
292+
print(f" {study_id}: {kept} cases ({skipped} skipped)")
293+
294+
295+
if __name__ == "__main__":
296+
main()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Studies to include in the env-triad eval.
2+
#
3+
# Pick studies that together cover multiple biomes so the eval can
4+
# distinguish models that actually read the biosample metadata from
5+
# ones that guess "terrestrial biome" blindly. See microbiomedata/nmdc-ai-eval#55.
6+
#
7+
# This list is a starting point. Expand with studies across aquatic,
8+
# marine, host-associated, built-environment, sediment, etc. Each
9+
# biosample in each study contributes one case (if it has complete
10+
# env-triad term data); skipped biosamples are counted in the run
11+
# summary.
12+
#
13+
# Each entry:
14+
# id: NMDC study ID
15+
# name: Human-readable label (informational)
16+
# expected_biomes: (optional) biomes you expect to see — helps future
17+
# reviewers sanity-check that the selection is diverse
18+
19+
studies:
20+
- id: nmdc:sty-11-r2h77870
21+
name: Bioscales
22+
expected_biomes:
23+
- terrestrial biome [ENVO:00000446]

0 commit comments

Comments
 (0)