use anda_cognitive_nexus::content_digest;
use anda_kip::{Json, KipError, cognitive::ArtifactPin};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::{BTreeMap, BTreeSet};
use super::{ExecutionContract, invalid, is_digest, is_revision};
pub(super) const MAX_PAIRS: usize = 4096;
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairCase {
pub task_digest: String,
pub initial_state_digest: String,
pub seed: String,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PairedTrialPlan {
pub task_family: String,
pub candidate_revision: String,
pub environment_digest: String,
pub base_memory_digest: String,
pub model_digest: String,
pub budget_digest: String,
pub tool_versions: BTreeMap<String, String>,
pub observation_window: String,
pub pairs: BTreeMap<String, PairCase>,
pub alpha: f64,
pub minimum_effect: f64,
pub execution: ExecutionContract,
}
impl PairedTrialPlan {
pub fn validate(&self) -> Result<(), KipError> {
self.execution.validate()?;
if self.budget_digest != self.execution.budget.digest()?
|| self.task_family != super::workflow_contract()["task_family"]
|| self
.execution
.review_of
.as_ref()
.is_some_and(|basis| basis.revision_ref != self.candidate_revision)
{
return Err(invalid(
"execution contract disagrees with the trial identity",
));
}
if self.task_family.trim().is_empty()
|| self.task_family.len() > 256
|| self.observation_window.trim().is_empty()
|| self.observation_window.len() > 256
{
return Err(invalid(
"paired trial requires bounded family and observation window",
));
}
if !is_revision(&self.candidate_revision) {
return Err(invalid(
"paired trial requires an immutable candidate revision reference",
));
}
for digest in [
&self.environment_digest,
&self.base_memory_digest,
&self.model_digest,
&self.budget_digest,
] {
if !is_digest(digest) {
return Err(invalid(
"paired trial identities require canonical SHA-256 digests",
));
}
}
if self.tool_versions.is_empty()
|| self.tool_versions.len() > 64
|| self.tool_versions.iter().any(|(k, v)| {
k.trim().is_empty() || k.len() > 256 || v.trim().is_empty() || v.len() > 256
})
{
return Err(invalid(
"paired trial requires bounded, explicit tool versions",
));
}
if !(2..=MAX_PAIRS).contains(&self.pairs.len()) {
return Err(invalid(
"paired trial requires between 2 and 4096 predeclared pairs",
));
}
let mut cases = BTreeSet::new();
for (id, case) in &self.pairs {
if id.trim().is_empty()
|| id.len() > 128
|| !is_digest(&case.task_digest)
|| !is_digest(&case.initial_state_digest)
|| case.seed.trim().is_empty()
|| case.seed.len() > 128
|| !cases.insert((&case.task_digest, &case.initial_state_digest, &case.seed))
{
return Err(invalid(
"pair identities must be bounded, unique and digest-pinned",
));
}
}
if !self.alpha.is_finite()
|| self.alpha <= 0.0
|| self.alpha >= 1.0
|| !self.minimum_effect.is_finite()
|| self.minimum_effect <= 0.0
|| self.minimum_effect > 1.0
{
return Err(invalid(
"paired alpha must be in (0,1), minimum_effect in (0,1]",
));
}
Ok(())
}
pub fn artifact(&self) -> Result<Json, KipError> {
self.validate()?;
serde_json::to_value(self).map_err(|e| invalid(e.to_string()))
}
pub fn pin(&self) -> Result<ArtifactPin, KipError> {
let content_digest = content_digest(&self.artifact()?)?;
Ok(ArtifactPin {
artifact_ref: format!("kip:artifact:{content_digest}"),
content_digest,
})
}
pub fn attempt_context(&self, pair_id: &str) -> Result<Json, KipError> {
self.validate()?;
self.validated_attempt_context(pair_id)
}
pub(super) fn validated_attempt_context(&self, pair_id: &str) -> Result<Json, KipError> {
let case = self
.pairs
.get(pair_id)
.ok_or_else(|| invalid("pair was not enrolled"))?;
Ok(json!({
"pair_id": pair_id,
"task_family": self.task_family,
"task_digest": case.task_digest,
"initial_state_digest": case.initial_state_digest,
"seed": case.seed,
"base_memory_digest": self.base_memory_digest,
"model_digest": self.model_digest,
"budget_digest": self.budget_digest,
"stratum": "all"
}))
}
pub fn comparability(&self, observer_control_digest: &str) -> Result<Json, KipError> {
self.validate()?;
if !is_digest(observer_control_digest) {
return Err(invalid("invalid observer-control digest"));
}
Ok(json!({
"method": "paired",
"environment_digest": self.environment_digest,
"strata_weights": {"all": 1.0},
"metric": "success",
"minimum_effect": self.minimum_effect,
"uncertainty_rule": "paired-hoeffding-v1",
"missingness_policy": "count_as_failure",
"observer_control_digest": observer_control_digest,
"sampling_unit": "pair_id",
"correlation_policy": "independent_pairs"
}))
}
pub fn treatment_revisions(&self) -> Vec<String> {
vec![self.candidate_revision.clone()]
}
}