Skip to main content

eval_containers/
naming.rs

1//! Image-name and bake-target conventions — the single source of truth for
2//! how the CLI maps a (benchmark, agent, model) axis onto registry image refs
3//! and `docker buildx bake` target names.
4//!
5//! These were previously inlined as `format!` strings scattered across
6//! `build.rs`, `run.rs`, `inspect.rs`, and `images.rs`. Centralizing them here
7//! makes the conventions testable (see the unit tests below) and keeps every
8//! call site in agreement — the contract that benchmarks/agents/models are
9//! built and run against (src/RULES.md, benchmarks/RULES.md rule 24).
10
11/// `{registry}/benchmarks/<name>:<tag>` — the per-benchmark base image.
12pub fn benchmark_image(registry: &str, benchmark: &str, tag: &str) -> String {
13    format!("{registry}/benchmarks/{benchmark}:{tag}")
14}
15
16/// `{registry}/benchmarks/<name>-<task>:<tag>` — a per-task benchmark variant
17/// (swe-bench-style), built outside bake's static graph (BAKE.md). The task id is
18/// lowercased: Docker repo names + tags MUST be lowercase and some per-task ids
19/// carry uppercase (swe-bench-pro's `instance_NodeBB__…`). This affects only the
20/// image NAME — `EVAL_TASK_ID` keeps the real id at runtime.
21pub fn benchmark_task_image(registry: &str, benchmark: &str, task_id: &str, tag: &str) -> String {
22    format!(
23        "{registry}/benchmarks/{benchmark}-{}:{tag}",
24        task_id.to_lowercase()
25    )
26}
27
28/// `{registry}/agents/<name>:<tag>` — the per-agent image.
29pub fn agent_image(registry: &str, agent: &str, tag: &str) -> String {
30    format!("{registry}/agents/{agent}:{tag}")
31}
32
33/// `{registry}/models/<model>:<tag>` — the per-model gateway image.
34pub fn model_image(registry: &str, model: &str, tag: &str) -> String {
35    format!("{registry}/models/{model}:{tag}")
36}
37
38/// `{registry}/evals/<benchmark>--<agent>:<tag>` — the combined eval image
39/// (shared-env benchmarks: one image, task chosen at runtime).
40/// The `--` separator is load-bearing: the OpenShift flattener collapses it to
41/// a single `-` for imagestream names (see [`flatten_imagestream`]).
42pub fn eval_image(registry: &str, benchmark: &str, agent: &str, tag: &str) -> String {
43    format!("{registry}/evals/{benchmark}--{agent}:{tag}")
44}
45
46/// `{registry}/evals/<benchmark>-<task>--<agent>:<tag>` — the combined eval image
47/// for a **per-task** benchmark (each task bakes a separate image; the task id
48/// is part of the name, mirroring [`benchmark_task_image`]). Every surface
49/// (build / compose / container / job) MUST address per-task evals by this name
50/// (benchmarks/RULES.md — eval-image naming). The task id is lowercased for the
51/// same Docker-tag reason as [`benchmark_task_image`].
52pub fn eval_task_image(
53    registry: &str,
54    benchmark: &str,
55    task_id: &str,
56    agent: &str,
57    tag: &str,
58) -> String {
59    format!(
60        "{registry}/evals/{benchmark}-{}--{agent}:{tag}",
61        task_id.to_lowercase()
62    )
63}
64
65/// `{registry}/compose/<name>:latest` — a benchmark's published compose file.
66pub fn compose_artifact(registry: &str, benchmark: &str) -> String {
67    format!("{registry}/compose/{benchmark}:latest")
68}
69
70/// Bake target for an agent: `agent-<name>`.
71pub fn agent_bake_target(agent: &str) -> String {
72    format!("agent-{agent}")
73}
74
75/// Bake target for a benchmark: `benchmark-<name>`.
76pub fn benchmark_bake_target(benchmark: &str) -> String {
77    format!("benchmark-{benchmark}")
78}
79
80/// Bake target for a model: `model-<name>`, with `.` → `_` because HCL target
81/// names can't contain dots (e.g. `gpt-5.4` → `model-gpt-5_4`).
82pub fn model_bake_target(model: &str) -> String {
83    format!("model-{}", model.replace('.', "_"))
84}
85
86/// Nested image repo path → OpenShift imagestream name (single segment).
87/// `core`/`gateways` keep their prefix (`core/otel` → `core-otel`); the
88/// per-eval categories drop it (`benchmarks/aime` → `aime`,
89/// `evals/aime--codex` → `aime-codex`); dots and `--` collapse to `-`.
90pub fn flatten_imagestream(repo: &str) -> String {
91    let (cat, rest) = repo.split_once('/').unwrap_or(("", repo));
92    let name = rest.to_lowercase().replace('.', "-").replace("--", "-");
93    match cat {
94        "core" | "gateways" => format!("{cat}-{name}"),
95        _ => name,
96    }
97}
98
99/// Sanitize an axis-derived string into a valid Helm release name — a DNS-1123
100/// label: lowercase, each run of non-`[a-z0-9]` collapses to one `-`, no leading
101/// or trailing `-`, capped at Helm's 53-char limit. Job mode's release name is
102/// `<benchmark>-<agent>-task-<task>`; per-task task ids carry chars Helm forbids
103/// (SWE-bench's `sympy__sympy-24066` has `_`), so without this `run --mode job`
104/// can't even render the chart for a per-task benchmark. Sibling to
105/// [`flatten_imagestream`] — both make a name k8s-safe.
106pub fn release_name(s: &str) -> String {
107    let mut out = String::with_capacity(s.len());
108    let mut dash = false;
109    for c in s.chars() {
110        let c = c.to_ascii_lowercase();
111        if c.is_ascii_lowercase() || c.is_ascii_digit() {
112            out.push(c);
113            dash = false;
114        } else if !dash {
115            out.push('-');
116            dash = true;
117        }
118    }
119    out.truncate(53);
120    out.trim_matches('-').to_string()
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    const REG: &str = "quay.io/eval-containers";
127
128    #[test]
129    fn eval_image_uses_double_dash_separator() {
130        assert_eq!(
131            eval_image(REG, "aime", "claude-code", "2.5.0"),
132            "quay.io/eval-containers/evals/aime--claude-code:2.5.0"
133        );
134    }
135
136    #[test]
137    fn category_images_are_namespaced() {
138        assert_eq!(
139            benchmark_image(REG, "aime", "latest"),
140            "quay.io/eval-containers/benchmarks/aime:latest"
141        );
142        assert_eq!(
143            agent_image(REG, "codex", "latest"),
144            "quay.io/eval-containers/agents/codex:latest"
145        );
146        assert_eq!(
147            model_image(REG, "gpt-5.4--bifrost", "latest"),
148            "quay.io/eval-containers/models/gpt-5.4--bifrost:latest"
149        );
150    }
151
152    #[test]
153    fn per_task_variant_appends_task_id() {
154        assert_eq!(
155            benchmark_task_image(REG, "swe-bench", "42", "latest"),
156            "quay.io/eval-containers/benchmarks/swe-bench-42:latest"
157        );
158    }
159
160    #[test]
161    fn per_task_image_refs_lowercase_the_task_id() {
162        // Docker repo names/tags MUST be lowercase; swe-bench-pro ids carry uppercase.
163        assert_eq!(
164            benchmark_task_image(
165                REG,
166                "swe-bench-pro",
167                "instance_NodeBB__NodeBB-abc",
168                "latest"
169            ),
170            "quay.io/eval-containers/benchmarks/swe-bench-pro-instance_nodebb__nodebb-abc:latest"
171        );
172        assert_eq!(
173            eval_task_image(
174                REG,
175                "swe-bench-pro",
176                "instance_NodeBB__NodeBB-abc",
177                "claude-code",
178                "latest"
179            ),
180            "quay.io/eval-containers/evals/swe-bench-pro-instance_nodebb__nodebb-abc--claude-code:latest"
181        );
182    }
183
184    #[test]
185    fn eval_task_image_carries_task_before_agent() {
186        assert_eq!(
187            eval_task_image(
188                REG,
189                "swe-bench",
190                "sympy__sympy-24066",
191                "claude-code",
192                "latest"
193            ),
194            "quay.io/eval-containers/evals/swe-bench-sympy__sympy-24066--claude-code:latest"
195        );
196    }
197
198    #[test]
199    fn model_bake_target_replaces_dots() {
200        assert_eq!(model_bake_target("gpt-5.4"), "model-gpt-5_4");
201        assert_eq!(
202            model_bake_target("gpt-5.4--bifrost"),
203            "model-gpt-5_4--bifrost"
204        );
205        assert_eq!(model_bake_target("replay"), "model-replay");
206    }
207
208    #[test]
209    fn flatten_drops_eval_categories_keeps_core() {
210        assert_eq!(flatten_imagestream("core/otel"), "core-otel");
211        assert_eq!(flatten_imagestream("gateways/bifrost"), "gateways-bifrost");
212        assert_eq!(flatten_imagestream("benchmarks/aime"), "aime");
213        assert_eq!(flatten_imagestream("evals/aime--codex"), "aime-codex");
214        assert_eq!(flatten_imagestream("models/gpt-5.4"), "gpt-5-4");
215    }
216
217    #[test]
218    fn release_name_sanitizes_to_a_dns_label() {
219        // SWE-bench task ids carry `__`, which Helm rejects in a release name.
220        assert_eq!(
221            release_name("swe-bench-claude-code-task-sympy__sympy-24066"),
222            "swe-bench-claude-code-task-sympy-sympy-24066"
223        );
224        // Already-valid names pass through unchanged.
225        assert_eq!(
226            release_name("aime-claude-code-task-0"),
227            "aime-claude-code-task-0"
228        );
229    }
230}