Skip to main content

agentics_contracts/validation/
public_api.rs

1//! Shared validation for public read API query contracts.
2
3use agentics_domain::models::challenge::ChallengeBundleSpec;
4use agentics_domain::models::names::{ChallengeKeyword, TargetName};
5use agentics_error::{Result, ServiceError};
6
7/// Default public challenge catalog page size.
8pub const DEFAULT_PUBLIC_CHALLENGE_LIST_LIMIT: i64 = 100;
9/// Default visible public solution submission page size.
10pub const DEFAULT_PUBLIC_SUBMISSION_LIST_LIMIT: i64 = 20;
11/// Default leaderboard page size.
12pub const DEFAULT_PUBLIC_LEADERBOARD_LIMIT: i64 = 50;
13/// Maximum page size for public list-style reads.
14pub const MAX_PUBLIC_LIST_LIMIT: i64 = 100;
15
16/// Bounded pagination parameters for a public collection endpoint.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct PublicPagination {
19    pub limit: i64,
20    pub offset: i64,
21}
22
23/// Validated public challenge catalog query.
24#[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    /// Parse and validate the public challenge catalog query contract.
34    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
96/// Validate a public list limit without silently widening expensive reads.
97pub 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
111/// Validate a public list offset without allowing negative pagination cursors.
112pub 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
122/// Validate limit and offset together for public list endpoints.
123pub 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
135/// Resolve an explicit public target query against the challenge spec.
136pub 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
156/// Resolve an optional public target filter against the challenge spec.
157pub 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}