1use agentics_domain::models::challenge::ChallengeBundleSpec;
4use agentics_domain::models::names::{ChallengeKeyword, TargetName};
5use agentics_error::{Result, ServiceError};
6
7pub const DEFAULT_PUBLIC_CHALLENGE_LIST_LIMIT: i64 = 100;
9pub const DEFAULT_PUBLIC_SUBMISSION_LIST_LIMIT: i64 = 20;
11pub const DEFAULT_PUBLIC_LEADERBOARD_LIMIT: i64 = 50;
13pub const MAX_PUBLIC_LIST_LIMIT: i64 = 100;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct PublicPagination {
19 pub limit: i64,
20 pub offset: i64,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct PublicChallengeCatalogQuery {
26 pub limit: i64,
27 pub offset: i64,
28 pub search: Option<String>,
29 pub keywords: Vec<ChallengeKeyword>,
30}
31
32impl PublicChallengeCatalogQuery {
33 pub fn try_from_raw_parts(
35 limit: Option<&str>,
36 offset: Option<&str>,
37 search: Option<String>,
38 keywords: Vec<String>,
39 ) -> Result<Self> {
40 let limit = parse_optional_i64(limit, "limit")?;
41 let offset = parse_optional_i64(offset, "offset")?;
42 let page = public_pagination(
43 limit,
44 offset,
45 DEFAULT_PUBLIC_CHALLENGE_LIST_LIMIT,
46 "challenge list",
47 )?;
48 Ok(Self {
49 limit: page.limit,
50 offset: page.offset,
51 search: normalized_challenge_search(search.as_deref())?,
52 keywords: parse_challenge_keywords(keywords)?,
53 })
54 }
55}
56
57fn parse_optional_i64(value: Option<&str>, field: &str) -> Result<Option<i64>> {
58 value
59 .map(|value| {
60 value
61 .parse::<i64>()
62 .map_err(|_| ServiceError::BadRequest(format!("{field} must be an integer")))
63 })
64 .transpose()
65}
66
67fn parse_challenge_keywords(raw: Vec<String>) -> Result<Vec<ChallengeKeyword>> {
68 if raw.len() > 6 {
69 return Err(ServiceError::Validation(
70 "challenge catalog filters accept at most 6 keywords".to_string(),
71 ));
72 }
73 raw.into_iter()
74 .map(ChallengeKeyword::try_new)
75 .collect::<std::result::Result<Vec<_>, _>>()
76 .map_err(|e| ServiceError::Validation(e.to_string()))
77}
78
79fn normalized_challenge_search(raw: Option<&str>) -> Result<Option<String>> {
80 let Some(raw) = raw else {
81 return Ok(None);
82 };
83 let normalized = raw.trim();
84 if normalized.is_empty() {
85 return Ok(None);
86 }
87 if normalized.len() > 120 || normalized.chars().any(char::is_control) {
88 return Err(ServiceError::Validation(
89 "challenge search query must be at most 120 UTF-8 bytes and contain no control characters"
90 .to_string(),
91 ));
92 }
93 Ok(Some(normalized.to_string()))
94}
95
96pub fn bounded_public_limit(
98 requested: Option<i64>,
99 default_limit: i64,
100 label: &str,
101) -> Result<i64> {
102 let limit = requested.unwrap_or(default_limit);
103 if !(1..=MAX_PUBLIC_LIST_LIMIT).contains(&limit) {
104 return Err(ServiceError::BadRequest(format!(
105 "{label} limit must be between 1 and {MAX_PUBLIC_LIST_LIMIT}"
106 )));
107 }
108 Ok(limit)
109}
110
111pub fn bounded_public_offset(requested: Option<i64>, label: &str) -> Result<i64> {
113 let offset = requested.unwrap_or(0);
114 if offset < 0 {
115 return Err(ServiceError::BadRequest(format!(
116 "{label} offset must be greater than or equal to 0"
117 )));
118 }
119 Ok(offset)
120}
121
122pub fn public_pagination(
124 requested_limit: Option<i64>,
125 requested_offset: Option<i64>,
126 default_limit: i64,
127 label: &str,
128) -> Result<PublicPagination> {
129 Ok(PublicPagination {
130 limit: bounded_public_limit(requested_limit, default_limit, label)?,
131 offset: bounded_public_offset(requested_offset, label)?,
132 })
133}
134
135pub fn resolve_required_public_target(
137 spec: &ChallengeBundleSpec,
138 requested_target: Option<&str>,
139) -> Result<TargetName> {
140 let Some(target) = requested_target else {
141 return Err(ServiceError::BadRequest(
142 "target query parameter is required".to_string(),
143 ));
144 };
145 let target = target
146 .parse::<TargetName>()
147 .map_err(|e| ServiceError::BadRequest(e.to_string()))?;
148 if spec.target(&target).is_some() {
149 return Ok(target);
150 }
151 Err(ServiceError::BadRequest(format!(
152 "challenge does not support target `{target}`"
153 )))
154}
155
156pub fn resolve_optional_public_target(
158 spec: &ChallengeBundleSpec,
159 requested_target: Option<&str>,
160) -> Result<Option<TargetName>> {
161 requested_target
162 .map(|target| resolve_required_public_target(spec, Some(target)))
163 .transpose()
164}
165
166#[cfg(test)]
167mod tests {
168 use crate::zip_project::ZIP_PROJECT_PROTOCOL;
169 use agentics_domain::models::challenge::{
170 ChallengeBundleSpec, ChallengeEligibilitySpec, ChallengeEligibilityType,
171 ChallengeExecutionSpec, ChallengeSolutionPublicationPolicy, ChallengeVisibility,
172 ChallengeVisibilitySpec, DatasetsSpec, EvaluatorSpec, PrivateBenchmarkPolicy,
173 PublicChallengeBundleSpec, SeparatedEvaluatorExecutionSpec, SolutionSpec,
174 };
175 use agentics_domain::models::evaluation::ScoreVisibility;
176 use agentics_domain::models::localization::LocalizedText;
177 use agentics_domain::models::names::{ChallengeKeyword, ChallengeName, TargetName};
178 use agentics_domain::models::paths::BundleRelativePath;
179
180 use super::{
181 DEFAULT_PUBLIC_CHALLENGE_LIST_LIMIT, PublicChallengeCatalogQuery, bounded_public_limit,
182 public_pagination, resolve_required_public_target,
183 };
184
185 fn target_name(value: &str) -> TargetName {
186 TargetName::try_new(value.to_string()).expect("target")
187 }
188
189 fn challenge_keyword(value: &str) -> ChallengeKeyword {
190 ChallengeKeyword::try_new(value.to_string()).expect("keyword")
191 }
192
193 fn spec() -> ChallengeBundleSpec {
194 let public: PublicChallengeBundleSpec =
195 serde_json::from_value(serde_json::json!({
196 "schema_version": 1,
197 "challenge_name": "sample-sum",
198 "challenge_title": "Sample Sum",
199 "summary": {"en": "Sum numbers", "zh": "Sum numbers zh"},
200 "keywords": ["arithmetic"],
201 "solution": {"protocol": ZIP_PROJECT_PROTOCOL, "manifest_file": "agentics.solution.json"},
202 "targets": [{
203 "name": "linux-arm64-cpu",
204 "docker_platform": "linux/arm64",
205 "accelerator": null,
206 "validation_enabled": true,
207 "resource_profile": {
208 "name": "agentics-small",
209 "resource_description": null,
210 "solution_image": {"source": "local", "reference": "agentics-linux-arm64-cpu:ubuntu26.04-local"},
211 "evaluator_image": {"source": "local", "reference": "agentics-linux-arm64-cpu:ubuntu26.04-local"},
212 "solution": {
213 "setup": {"timeout_sec": 30, "memory_limit_mb": 512, "cpu_limit_millis": 1000, "disk_limit_mb": 1024, "network_access": "disabled"},
214 "build": {"timeout_sec": 30, "memory_limit_mb": 512, "cpu_limit_millis": 1000, "disk_limit_mb": 1024, "network_access": "disabled"},
215 "run": {"timeout_sec": 30, "memory_limit_mb": 512, "cpu_limit_millis": 1000, "disk_limit_mb": 1024, "network_access": "disabled"}
216 },
217 "evaluator": {
218 "setup": {"timeout_sec": 30, "memory_limit_mb": 512, "cpu_limit_millis": 1000, "disk_limit_mb": 1024, "network_access": "disabled"},
219 "run": {"timeout_sec": 30, "memory_limit_mb": 512, "cpu_limit_millis": 1000, "disk_limit_mb": 1024, "network_access": "disabled"}
220 },
221 "hardware_metadata": null
222 }
223 }],
224 "starts_at": "2026-01-01T00:00:00Z",
225 "closes_at": null,
226 "eligibility": {"type": "open"},
227 "validation_submission_limit": null,
228 "official_submission_limit": null,
229 "visibility": {
230 "leaderboard": "public_live",
231 "score_distribution": "public_live",
232 "result_detail": "submitter_live_public_live"
233 },
234 "solution_publication": "private",
235 "execution": {
236 "mode": "separated_evaluator",
237 "separated_evaluator": {"command": ["python", "separated-evaluator/run.py"], "result_file": "result.json"},
238 "validation_runs": null,
239 "validation_setup": null
240 },
241 "datasets": {
242 "public_dir": "public",
243 "public_policy": "full",
244 "private_benchmark_policy": "score_only",
245 "private_benchmark_enabled": false
246 },
247 "metric_schema": {
248 "metrics": [{"name": "score", "label": "Score", "unit": null, "direction": "maximize", "visibility": "public", "metric_description": "Challenge-defined compatibility score."}],
249 "ranking": {"primary_metric_name": "score", "tie_breaker_metric_names": null}
250 }
251 }))
252 .expect("fixture should deserialize");
253 ChallengeBundleSpec {
254 schema_version: public.schema_version,
255 challenge_name: ChallengeName::try_new("sample-sum".to_string()).expect("name"),
256 challenge_title: public.challenge_title,
257 summary: LocalizedText {
258 en: "Sum numbers".to_string(),
259 zh: "Sum numbers zh".to_string(),
260 },
261 keywords: vec![challenge_keyword("arithmetic")],
262 solution: SolutionSpec {
263 protocol: ZIP_PROJECT_PROTOCOL.to_string(),
264 manifest_file: BundleRelativePath::try_new("agentics.solution.json")
265 .expect("path"),
266 },
267 targets: public.targets,
268 starts_at: "2026-01-01T00:00:00Z".to_string(),
269 closes_at: None,
270 eligibility: ChallengeEligibilitySpec {
271 eligibility_type: ChallengeEligibilityType::Open,
272 },
273 validation_submission_limit: None,
274 official_submission_limit: None,
275 visibility: ChallengeVisibilitySpec {
276 leaderboard: ChallengeVisibility::PublicLive,
277 score_distribution: ChallengeVisibility::PublicLive,
278 result_detail:
279 agentics_domain::models::challenge::ChallengeResultDetailVisibility::SubmitterLivePublicLive,
280 },
281 solution_publication: ChallengeSolutionPublicationPolicy::Private,
282 execution: ChallengeExecutionSpec::SeparatedEvaluator(SeparatedEvaluatorExecutionSpec {
283 separated_evaluator: EvaluatorSpec {
284 command: vec!["python".to_string(), "separated-evaluator/run.py".to_string()],
285 result_file: BundleRelativePath::try_new("result.json").expect("path"),
286 },
287 validation_runs: None,
288 validation_setup: None,
289 official_runs: None,
290 official_evaluation_setup: None,
291 }),
292 datasets: DatasetsSpec {
293 public_dir: BundleRelativePath::try_new("public").expect("path"),
294 private_benchmark_dir: None,
295 public_policy: ScoreVisibility::Full,
296 private_benchmark_policy: PrivateBenchmarkPolicy::ScoreOnly,
297 private_benchmark_enabled: false,
298 },
299 metric_schema: public.metric_schema,
300 }
301 }
302
303 #[test]
304 fn validates_public_pagination() {
305 let page = public_pagination(
306 None,
307 None,
308 DEFAULT_PUBLIC_CHALLENGE_LIST_LIMIT,
309 "challenge list",
310 )
311 .expect("default page should validate");
312 assert_eq!(page.limit, 100);
313 assert_eq!(page.offset, 0);
314 assert!(bounded_public_limit(Some(0), 100, "items").is_err());
315 assert!(public_pagination(Some(1), Some(-1), 100, "items").is_err());
316 }
317
318 #[test]
319 fn validates_public_challenge_catalog_queries() {
320 let query = PublicChallengeCatalogQuery::try_from_raw_parts(
321 Some("25"),
322 Some("5"),
323 Some(" matrix ".to_string()),
324 vec!["systems".to_string(), "math".to_string()],
325 )
326 .expect("catalog query should validate");
327 assert_eq!(query.limit, 25);
328 assert_eq!(query.offset, 5);
329 assert_eq!(query.search.as_deref(), Some("matrix"));
330 assert_eq!(query.keywords.len(), 2);
331
332 assert!(
333 PublicChallengeCatalogQuery::try_from_raw_parts(Some("abc"), None, None, Vec::new())
334 .is_err()
335 );
336 assert!(
337 PublicChallengeCatalogQuery::try_from_raw_parts(
338 None,
339 None,
340 Some("x".repeat(121)),
341 Vec::new()
342 )
343 .is_err()
344 );
345 assert!(
346 PublicChallengeCatalogQuery::try_from_raw_parts(
347 None,
348 None,
349 None,
350 vec![
351 "one".to_string(),
352 "two".to_string(),
353 "three".to_string(),
354 "four".to_string(),
355 "five".to_string(),
356 "six".to_string(),
357 "seven".to_string(),
358 ],
359 )
360 .is_err()
361 );
362 }
363
364 #[test]
365 fn resolves_required_public_target() {
366 let spec = spec();
367 assert_eq!(
368 resolve_required_public_target(&spec, Some("linux-arm64-cpu")).expect("target"),
369 target_name("linux-arm64-cpu")
370 );
371 assert!(resolve_required_public_target(&spec, None).is_err());
372 assert!(resolve_required_public_target(&spec, Some("linux-arm64-cuda")).is_err());
373 }
374}