1use std::collections::BTreeSet;
18
19use feagi_dataset_contracts::{
20 BackendKind, ConnectomeHash, ContentHash, DatasetAssetId, EvaluationProtocolVersion, PluginRef,
21 ScorecardId, SplitId,
22};
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26#[cfg(test)]
27mod tests;
28
29pub const SCHEMA_VERSION: u32 = 1;
31
32const SHA256_PREFIX: &str = "sha256:";
34const SHA256_HEX_LEN: usize = 64;
36
37#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
39#[serde(transparent)]
40pub struct EvaluationId(pub String);
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
44#[serde(transparent)]
45pub struct ExperimentId(pub String);
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum FitnessObjective {
51 Maximize,
53 Minimize,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct FitnessSpec {
60 pub metric: String,
62 pub split_id: SplitId,
64 pub objective: FitnessObjective,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ComparabilityKey {
73 pub experiment_id: ExperimentId,
75 pub dataset_asset_id: DatasetAssetId,
77 pub dataset_version: String,
79 pub dataset_content_hash: ContentHash,
81 pub evaluation_protocol_version: EvaluationProtocolVersion,
83 pub metric_pack: PluginRef,
85 pub reward_policy: PluginRef,
87 pub fitness: FitnessSpec,
89 pub run_config_hash: ContentHash,
91 pub genome_schema_version: u32,
93 pub feagi_core_version: String,
95 pub backend: BackendKind,
97}
98
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101pub struct ConfidenceInterval {
102 pub low: f64,
104 pub high: f64,
106 pub level: f64,
108}
109
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112pub struct FitnessEstimate {
113 pub value: f64,
115 pub n: u32,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub interval: Option<ConfidenceInterval>,
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[serde(tag = "status", rename_all = "snake_case")]
125pub enum FitnessOutcome {
126 Scored(FitnessEstimate),
128 NoFitnessSplit,
130 Incomplete,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "snake_case")]
137pub enum GenomeOrigin {
138 Manual,
140 Imported,
142 Mutation,
144 Crossover,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct Lineage {
151 pub origin: GenomeOrigin,
153 pub generation: u32,
155 pub parents: Vec<ContentHash>,
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct GenomeEvaluation {
162 pub schema_version: u32,
164 pub evaluation_id: EvaluationId,
166 pub genome_hash: ContentHash,
168 pub key: ComparabilityKey,
170 pub scorecard_ids: Vec<ScorecardId>,
172 pub fitness: FitnessOutcome,
174 pub lineage: Lineage,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub pinned_connectome: Option<ConnectomeHash>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Error)]
183pub enum EvaluationError {
184 #[error("unsupported schema_version {found}; expected {expected}")]
186 SchemaVersion {
187 found: u32,
189 expected: u32,
191 },
192 #[error("field `{0}` must not be empty")]
194 EmptyField(&'static str),
195 #[error("field `{field}` is not a sha256 content hash: {value}")]
197 InvalidHash {
198 field: &'static str,
200 value: String,
202 },
203 #[error("scorecard_ids must not be empty")]
205 NoScorecards,
206 #[error("duplicate scorecard id {0}")]
208 DuplicateScorecard(String),
209 #[error("invalid fitness: {0}")]
211 InvalidFitness(String),
212 #[error("invalid lineage: {0}")]
214 InvalidLineage(String),
215}
216
217impl GenomeEvaluation {
218 pub fn selectable_fitness(&self) -> Option<&FitnessEstimate> {
223 match &self.fitness {
224 FitnessOutcome::Scored(estimate) => Some(estimate),
225 FitnessOutcome::NoFitnessSplit | FitnessOutcome::Incomplete => None,
226 }
227 }
228
229 pub fn validate(&self) -> Result<(), EvaluationError> {
234 if self.schema_version != SCHEMA_VERSION {
235 return Err(EvaluationError::SchemaVersion {
236 found: self.schema_version,
237 expected: SCHEMA_VERSION,
238 });
239 }
240 require_text("evaluation_id", &self.evaluation_id.0)?;
241 require_sha256("genome_hash", &self.genome_hash)?;
242 validate_key(&self.key)?;
243 validate_scorecards(&self.scorecard_ids)?;
244 if let FitnessOutcome::Scored(estimate) = &self.fitness {
245 validate_estimate(estimate)?;
246 }
247 validate_lineage(&self.lineage, &self.genome_hash)?;
248 if let Some(connectome) = &self.pinned_connectome {
249 require_text("pinned_connectome", &connectome.0)?;
250 }
251 Ok(())
252 }
253}
254
255fn require_text(field: &'static str, value: &str) -> Result<(), EvaluationError> {
256 if value.trim().is_empty() {
257 return Err(EvaluationError::EmptyField(field));
258 }
259 Ok(())
260}
261
262fn require_sha256(field: &'static str, hash: &ContentHash) -> Result<(), EvaluationError> {
263 let digest_ok = hash.0.strip_prefix(SHA256_PREFIX).is_some_and(|hex| {
264 hex.len() == SHA256_HEX_LEN
265 && hex
266 .bytes()
267 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
268 });
269 if !digest_ok {
270 return Err(EvaluationError::InvalidHash {
271 field,
272 value: hash.0.clone(),
273 });
274 }
275 Ok(())
276}
277
278fn validate_key(key: &ComparabilityKey) -> Result<(), EvaluationError> {
279 require_text("key.experiment_id", &key.experiment_id.0)?;
280 require_text("key.dataset_asset_id", &key.dataset_asset_id.0)?;
281 require_text("key.dataset_version", &key.dataset_version)?;
282 require_text("key.dataset_content_hash", &key.dataset_content_hash.0)?;
283 require_text(
284 "key.evaluation_protocol_version",
285 &key.evaluation_protocol_version.0,
286 )?;
287 require_text("key.metric_pack.id", &key.metric_pack.id.0)?;
288 require_text("key.metric_pack.version", &key.metric_pack.version)?;
289 require_text("key.reward_policy.id", &key.reward_policy.id.0)?;
290 require_text("key.reward_policy.version", &key.reward_policy.version)?;
291 require_text("key.fitness.metric", &key.fitness.metric)?;
292 require_text("key.fitness.split_id", &key.fitness.split_id.0)?;
293 require_sha256("key.run_config_hash", &key.run_config_hash)?;
294 require_text("key.feagi_core_version", &key.feagi_core_version)?;
295 Ok(())
296}
297
298fn validate_scorecards(ids: &[ScorecardId]) -> Result<(), EvaluationError> {
299 if ids.is_empty() {
300 return Err(EvaluationError::NoScorecards);
301 }
302 let mut seen = BTreeSet::new();
303 for id in ids {
304 require_text("scorecard_ids[]", &id.0)?;
305 if !seen.insert(id.0.as_str()) {
306 return Err(EvaluationError::DuplicateScorecard(id.0.clone()));
307 }
308 }
309 Ok(())
310}
311
312fn validate_estimate(estimate: &FitnessEstimate) -> Result<(), EvaluationError> {
313 if !estimate.value.is_finite() {
314 return Err(EvaluationError::InvalidFitness(
315 "value must be finite".to_string(),
316 ));
317 }
318 match (estimate.n, &estimate.interval) {
319 (0, _) => Err(EvaluationError::InvalidFitness(
320 "n must be at least 1".to_string(),
321 )),
322 (1, None) => Ok(()),
323 (1, Some(_)) => Err(EvaluationError::InvalidFitness(
324 "a single run carries no interval".to_string(),
325 )),
326 (_, None) => Err(EvaluationError::InvalidFitness(
327 "a repeated estimate (n > 1) requires an interval".to_string(),
328 )),
329 (_, Some(interval)) => validate_interval(estimate.value, interval),
330 }
331}
332
333fn validate_interval(value: f64, interval: &ConfidenceInterval) -> Result<(), EvaluationError> {
334 let finite = interval.low.is_finite() && interval.high.is_finite();
335 if !finite || interval.low > value || value > interval.high {
336 return Err(EvaluationError::InvalidFitness(
337 "interval must be finite and contain the value".to_string(),
338 ));
339 }
340 if !(interval.level > 0.0 && interval.level < 1.0) {
341 return Err(EvaluationError::InvalidFitness(
342 "interval level must be in (0, 1)".to_string(),
343 ));
344 }
345 Ok(())
346}
347
348fn validate_lineage(lineage: &Lineage, genome_hash: &ContentHash) -> Result<(), EvaluationError> {
349 for parent in &lineage.parents {
350 require_sha256("lineage.parents[]", parent)?;
351 }
352 let unique: BTreeSet<&str> = lineage.parents.iter().map(|p| p.0.as_str()).collect();
353 if unique.len() != lineage.parents.len() {
354 return Err(EvaluationError::InvalidLineage(
355 "parents must be distinct".to_string(),
356 ));
357 }
358 if unique.contains(genome_hash.0.as_str()) {
359 return Err(EvaluationError::InvalidLineage(
360 "a genome cannot be its own parent".to_string(),
361 ));
362 }
363 let parent_count = lineage.parents.len();
364 match lineage.origin {
365 GenomeOrigin::Manual | GenomeOrigin::Imported => {
366 if parent_count != 0 || lineage.generation != 0 {
367 return Err(EvaluationError::InvalidLineage(
368 "manual and imported genomes are generation 0 with no parents".to_string(),
369 ));
370 }
371 }
372 GenomeOrigin::Mutation => {
373 if parent_count != 1 || lineage.generation == 0 {
374 return Err(EvaluationError::InvalidLineage(
375 "a mutation has exactly one parent and generation >= 1".to_string(),
376 ));
377 }
378 }
379 GenomeOrigin::Crossover => {
380 if parent_count < 2 || lineage.generation == 0 {
381 return Err(EvaluationError::InvalidLineage(
382 "a crossover has at least two parents and generation >= 1".to_string(),
383 ));
384 }
385 }
386 }
387 Ok(())
388}