use std::collections::BTreeSet;
use feagi_dataset_contracts::{
BackendKind, ConnectomeHash, ContentHash, DatasetAssetId, EvaluationProtocolVersion, PluginRef,
ScorecardId, SplitId,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(test)]
mod tests;
pub const SCHEMA_VERSION: u32 = 1;
const SHA256_PREFIX: &str = "sha256:";
const SHA256_HEX_LEN: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EvaluationId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ExperimentId(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FitnessObjective {
Maximize,
Minimize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FitnessSpec {
pub metric: String,
pub split_id: SplitId,
pub objective: FitnessObjective,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ComparabilityKey {
pub experiment_id: ExperimentId,
pub dataset_asset_id: DatasetAssetId,
pub dataset_version: String,
pub dataset_content_hash: ContentHash,
pub evaluation_protocol_version: EvaluationProtocolVersion,
pub metric_pack: PluginRef,
pub reward_policy: PluginRef,
pub fitness: FitnessSpec,
pub run_config_hash: ContentHash,
pub genome_schema_version: u32,
pub feagi_core_version: String,
pub backend: BackendKind,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConfidenceInterval {
pub low: f64,
pub high: f64,
pub level: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FitnessEstimate {
pub value: f64,
pub n: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<ConfidenceInterval>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum FitnessOutcome {
Scored(FitnessEstimate),
NoFitnessSplit,
Incomplete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GenomeOrigin {
Manual,
Imported,
Mutation,
Crossover,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Lineage {
pub origin: GenomeOrigin,
pub generation: u32,
pub parents: Vec<ContentHash>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GenomeEvaluation {
pub schema_version: u32,
pub evaluation_id: EvaluationId,
pub genome_hash: ContentHash,
pub key: ComparabilityKey,
pub scorecard_ids: Vec<ScorecardId>,
pub fitness: FitnessOutcome,
pub lineage: Lineage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pinned_connectome: Option<ConnectomeHash>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum EvaluationError {
#[error("unsupported schema_version {found}; expected {expected}")]
SchemaVersion {
found: u32,
expected: u32,
},
#[error("field `{0}` must not be empty")]
EmptyField(&'static str),
#[error("field `{field}` is not a sha256 content hash: {value}")]
InvalidHash {
field: &'static str,
value: String,
},
#[error("scorecard_ids must not be empty")]
NoScorecards,
#[error("duplicate scorecard id {0}")]
DuplicateScorecard(String),
#[error("invalid fitness: {0}")]
InvalidFitness(String),
#[error("invalid lineage: {0}")]
InvalidLineage(String),
}
impl GenomeEvaluation {
pub fn selectable_fitness(&self) -> Option<&FitnessEstimate> {
match &self.fitness {
FitnessOutcome::Scored(estimate) => Some(estimate),
FitnessOutcome::NoFitnessSplit | FitnessOutcome::Incomplete => None,
}
}
pub fn validate(&self) -> Result<(), EvaluationError> {
if self.schema_version != SCHEMA_VERSION {
return Err(EvaluationError::SchemaVersion {
found: self.schema_version,
expected: SCHEMA_VERSION,
});
}
require_text("evaluation_id", &self.evaluation_id.0)?;
require_sha256("genome_hash", &self.genome_hash)?;
validate_key(&self.key)?;
validate_scorecards(&self.scorecard_ids)?;
if let FitnessOutcome::Scored(estimate) = &self.fitness {
validate_estimate(estimate)?;
}
validate_lineage(&self.lineage, &self.genome_hash)?;
if let Some(connectome) = &self.pinned_connectome {
require_text("pinned_connectome", &connectome.0)?;
}
Ok(())
}
}
fn require_text(field: &'static str, value: &str) -> Result<(), EvaluationError> {
if value.trim().is_empty() {
return Err(EvaluationError::EmptyField(field));
}
Ok(())
}
fn require_sha256(field: &'static str, hash: &ContentHash) -> Result<(), EvaluationError> {
let digest_ok = hash.0.strip_prefix(SHA256_PREFIX).is_some_and(|hex| {
hex.len() == SHA256_HEX_LEN
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
});
if !digest_ok {
return Err(EvaluationError::InvalidHash {
field,
value: hash.0.clone(),
});
}
Ok(())
}
fn validate_key(key: &ComparabilityKey) -> Result<(), EvaluationError> {
require_text("key.experiment_id", &key.experiment_id.0)?;
require_text("key.dataset_asset_id", &key.dataset_asset_id.0)?;
require_text("key.dataset_version", &key.dataset_version)?;
require_text("key.dataset_content_hash", &key.dataset_content_hash.0)?;
require_text(
"key.evaluation_protocol_version",
&key.evaluation_protocol_version.0,
)?;
require_text("key.metric_pack.id", &key.metric_pack.id.0)?;
require_text("key.metric_pack.version", &key.metric_pack.version)?;
require_text("key.reward_policy.id", &key.reward_policy.id.0)?;
require_text("key.reward_policy.version", &key.reward_policy.version)?;
require_text("key.fitness.metric", &key.fitness.metric)?;
require_text("key.fitness.split_id", &key.fitness.split_id.0)?;
require_sha256("key.run_config_hash", &key.run_config_hash)?;
require_text("key.feagi_core_version", &key.feagi_core_version)?;
Ok(())
}
fn validate_scorecards(ids: &[ScorecardId]) -> Result<(), EvaluationError> {
if ids.is_empty() {
return Err(EvaluationError::NoScorecards);
}
let mut seen = BTreeSet::new();
for id in ids {
require_text("scorecard_ids[]", &id.0)?;
if !seen.insert(id.0.as_str()) {
return Err(EvaluationError::DuplicateScorecard(id.0.clone()));
}
}
Ok(())
}
fn validate_estimate(estimate: &FitnessEstimate) -> Result<(), EvaluationError> {
if !estimate.value.is_finite() {
return Err(EvaluationError::InvalidFitness(
"value must be finite".to_string(),
));
}
match (estimate.n, &estimate.interval) {
(0, _) => Err(EvaluationError::InvalidFitness(
"n must be at least 1".to_string(),
)),
(1, None) => Ok(()),
(1, Some(_)) => Err(EvaluationError::InvalidFitness(
"a single run carries no interval".to_string(),
)),
(_, None) => Err(EvaluationError::InvalidFitness(
"a repeated estimate (n > 1) requires an interval".to_string(),
)),
(_, Some(interval)) => validate_interval(estimate.value, interval),
}
}
fn validate_interval(value: f64, interval: &ConfidenceInterval) -> Result<(), EvaluationError> {
let finite = interval.low.is_finite() && interval.high.is_finite();
if !finite || interval.low > value || value > interval.high {
return Err(EvaluationError::InvalidFitness(
"interval must be finite and contain the value".to_string(),
));
}
if !(interval.level > 0.0 && interval.level < 1.0) {
return Err(EvaluationError::InvalidFitness(
"interval level must be in (0, 1)".to_string(),
));
}
Ok(())
}
fn validate_lineage(lineage: &Lineage, genome_hash: &ContentHash) -> Result<(), EvaluationError> {
for parent in &lineage.parents {
require_sha256("lineage.parents[]", parent)?;
}
let unique: BTreeSet<&str> = lineage.parents.iter().map(|p| p.0.as_str()).collect();
if unique.len() != lineage.parents.len() {
return Err(EvaluationError::InvalidLineage(
"parents must be distinct".to_string(),
));
}
if unique.contains(genome_hash.0.as_str()) {
return Err(EvaluationError::InvalidLineage(
"a genome cannot be its own parent".to_string(),
));
}
let parent_count = lineage.parents.len();
match lineage.origin {
GenomeOrigin::Manual | GenomeOrigin::Imported => {
if parent_count != 0 || lineage.generation != 0 {
return Err(EvaluationError::InvalidLineage(
"manual and imported genomes are generation 0 with no parents".to_string(),
));
}
}
GenomeOrigin::Mutation => {
if parent_count != 1 || lineage.generation == 0 {
return Err(EvaluationError::InvalidLineage(
"a mutation has exactly one parent and generation >= 1".to_string(),
));
}
}
GenomeOrigin::Crossover => {
if parent_count < 2 || lineage.generation == 0 {
return Err(EvaluationError::InvalidLineage(
"a crossover has at least two parents and generation >= 1".to_string(),
));
}
}
}
Ok(())
}