wyrd-spec 0.1.1

Wyrd foundation types: Card envelope, SpecKinds, and pure data contracts.
//! `EvalSpec` — the typed spec body carried by a `kind: Verifier` card
//! whose `implementation.kind` is `eval`.
//!
//! Envelope-level fields (`metadata.space`, `metadata.name`,
//! `metadata.version`, `apiVersion`) live on the outer `Card`, not here.

use std::collections::BTreeMap;

use serde::{Deserialize, Deserializer, Serialize};

use crate::envelope::CardKind;
use crate::error::WyrdError;
use crate::reference::{CardRef, Ref};

use super::ids::{JsonPath, TaskId};
use super::operator::{ComparisonOperator, validate_regex_pattern};
use super::plan::{DagError, ExecutionPlan, validate_dag};
use super::result::{EvalContextCapture, EvalPassGate};
use super::task::EvalTask;
use super::workflow::Workflow;

/// Maximum number of tasks allowed in an [`EvalSpec`] task DAG.
///
/// Guards against unbounded O(V+E) allocation in DAG validation before any
/// auth or quota check. Mirrors `MAX_SCENARIOS_PER_COLLECTION` on the scenario
/// side.
pub const MAX_EVAL_TASKS: usize = 512;

/// The typed spec body of an `eval` Verifier implementation.
///
/// This struct does not carry a discriminator; the enclosing
/// `VerifierImplementation` union supplies the `implementation.kind: eval` and
/// `implementation.spec: { ... }` wire shape on a `Verifier` Card.
///
/// Authors declare:
/// - `dataset` — source data, typically a `Data` card with eval scenarios.
/// - `tasks` — the rubric DAG.
/// - `workflow` — optional declared workflow shape.
/// - `sampling` — optional production sampling policy.
/// - `pass_gate` — optional workflow-level pass criterion.
/// - `context_capture` — durable storage policy for `AssertionResult::actual`.
///
/// This struct does not carry envelope identity, tenant partitioning, triggers,
/// or alert dispatch. Alert wiring is deferred until the drift primitives and
/// secret reference model are locked.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct EvalSpec {
    /// Source data for offline runs.
    ///
    /// Validation enforces `dataset.kind == Data`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dataset: Option<DatasetRef>,

    /// Task DAG keyed by `TaskId`.
    pub tasks: BTreeMap<TaskId, EvalTask>,

    /// Optional declared workflow shape used as a path validation hint.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workflow: Option<Workflow>,

    /// Optional sampling policy applied at the record-ingest boundary.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub sampling: Option<EvalSampling>,

    /// Optional workflow-level pass gate.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pass_gate: Option<EvalPassGate>,

    /// How `vala-eval` captures extracted values in [`super::AssertionResult::actual`].
    ///
    /// Set to [`EvalContextCapture::Redact`] when this eval runs over
    /// production traffic where context fields may contain PII. Absent means
    /// [`EvalContextCapture::Full`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context_capture: Option<EvalContextCapture>,
}

impl EvalSpec {
    /// Construct an eval spec after validating its task DAG.
    ///
    /// The returned spec has no subject, dataset, workflow, sampling policy,
    /// pass gate, or context-capture override. Call [`EvalSpec::validate`]
    /// after mutating optional fields.
    ///
    /// # Errors
    /// Returns [`EvalSpecError::Wyrd`] when a task map key does not match its
    /// inner id, or [`EvalSpecError::Dag`] when the task map is not a DAG.
    pub fn new(tasks: BTreeMap<TaskId, EvalTask>) -> Result<Self, EvalSpecError> {
        if tasks.len() > MAX_EVAL_TASKS {
            return Err(EvalSpecError::Wyrd(WyrdError::Validation {
                message: format!(
                    "eval_spec.tasks count {} exceeds MAX_EVAL_TASKS {MAX_EVAL_TASKS}",
                    tasks.len()
                ),
                details: serde_json::Value::Null,
            }));
        }
        validate_task_keys(&tasks).map_err(EvalSpecError::Wyrd)?;
        validate_dag(&tasks).map_err(EvalSpecError::Dag)?;
        Ok(Self {
            dataset: None,
            tasks,
            workflow: None,
            sampling: None,
            pass_gate: None,
            context_capture: None,
        })
    }

    /// Validate the task DAG, task-level validators, dataset, pass gate, and
    /// sampling policy.
    ///
    /// # Errors
    /// Returns [`EvalSpecError`] when DAG validation fails or a nested Wyrd
    /// validation rule rejects the spec.
    pub fn validate(&self) -> Result<(), EvalSpecError> {
        if self.tasks.len() > MAX_EVAL_TASKS {
            return Err(EvalSpecError::Wyrd(WyrdError::Validation {
                message: format!(
                    "eval_spec.tasks count {} exceeds MAX_EVAL_TASKS {MAX_EVAL_TASKS}",
                    self.tasks.len()
                ),
                details: serde_json::Value::Null,
            }));
        }
        validate_task_keys(&self.tasks).map_err(EvalSpecError::Wyrd)?;
        validate_dag(&self.tasks).map_err(EvalSpecError::Dag)?;

        for task in self.tasks.values() {
            if let EvalTask::LlmJudge(task) = task {
                task.validate().map_err(EvalSpecError::Wyrd)?;
            }
            if let Some(condition) = task.condition() {
                condition.validate().map_err(EvalSpecError::Wyrd)?;
            }
            validate_task_operator(task).map_err(EvalSpecError::Wyrd)?;
        }

        if let Some(dataset) = &self.dataset {
            dataset.validate().map_err(EvalSpecError::Wyrd)?;
        }
        if let Some(pass_gate) = &self.pass_gate {
            pass_gate.validate().map_err(EvalSpecError::Wyrd)?;
        }
        if let Some(sampling) = &self.sampling {
            sampling.validate().map_err(EvalSpecError::Wyrd)?;
        }
        Ok(())
    }

    /// Return the topologically sorted execution plan.
    ///
    /// This method calls DAG validation but not full nested spec validation.
    ///
    /// # Errors
    /// Returns [`DagError`] when the task map is not a DAG.
    pub fn execution_plan(&self) -> Result<ExecutionPlan, DagError> {
        validate_dag(&self.tasks)
    }
}

/// `EvalSpec` validation aggregate.
#[derive(Debug, thiserror::Error)]
pub enum EvalSpecError {
    /// The task map failed DAG validation.
    #[error("dag validation failed: {0}")]
    Dag(#[from] DagError),
    /// A nested Wyrd validation rule failed.
    #[error("validation failed: {0}")]
    Wyrd(#[from] WyrdError),
}

impl From<EvalSpecError> for WyrdError {
    fn from(error: EvalSpecError) -> Self {
        match error {
            EvalSpecError::Dag(error) => error.into(),
            EvalSpecError::Wyrd(error) => error,
        }
    }
}

fn validate_task_operator(task: &EvalTask) -> Result<(), WyrdError> {
    let operator = match task {
        EvalTask::Assertion(t) => &t.operator,
        EvalTask::LlmJudge(t) => &t.operator,
        EvalTask::TraceAssertion(t) => &t.operator,
        EvalTask::AgentAssertion(t) => &t.operator,
    };
    match operator {
        ComparisonOperator::MatchesRegex { pattern }
        | ComparisonOperator::NotMatchesRegex { pattern } => validate_regex_pattern(pattern),
        _ => Ok(()),
    }
}

fn validate_task_keys(tasks: &BTreeMap<TaskId, EvalTask>) -> Result<(), WyrdError> {
    for (key, task) in tasks {
        if key != task.id() {
            return Err(WyrdError::Validation {
                message: format!(
                    "eval_spec.tasks key {:?} must match inner task id {:?}",
                    key.as_str(),
                    task.id().as_str()
                ),
                details: serde_json::json!({
                    "field": "tasks",
                    "key": key.as_str(),
                    "task_id": task.id().as_str(),
                }),
            });
        }
    }
    Ok(())
}

/// Thin `CardRef` wrapper constraining the referenced kind to `Data`.
///
/// Deserialization routes through [`DatasetRef::new`] so kind enforcement fires
/// at the wire boundary, consistent with [`super::ids::TaskId`] and
/// [`super::ids::JsonPath`].
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
#[serde(transparent)]
pub struct DatasetRef(pub Ref);

impl<'de> Deserialize<'de> for DatasetRef {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let reference = Ref::deserialize(deserializer)?;
        Self::new(reference).map_err(serde::de::Error::custom)
    }
}

impl DatasetRef {
    /// Construct a dataset reference, rejecting non-`Data` card kinds.
    ///
    /// # Errors
    /// Returns [`WyrdError::Validation`] when `card_ref.kind` is not
    /// [`CardKind::Data`].
    pub fn new(reference: Ref) -> Result<Self, WyrdError> {
        if let Some(card_ref) = reference.as_card_ref()
            && card_ref.kind != CardKind::Data
        {
            return Err(Self::kind_mismatch(&card_ref.kind));
        }
        Ok(Self(reference))
    }

    /// Re-validate after deserialization.
    ///
    /// # Errors
    /// Returns [`WyrdError::Validation`] when the wrapped reference is not a
    /// `Data` card reference.
    pub fn validate(&self) -> Result<(), WyrdError> {
        if let Some(card_ref) = self.0.as_card_ref()
            && card_ref.kind != CardKind::Data
        {
            return Err(Self::kind_mismatch(&card_ref.kind));
        }
        Ok(())
    }

    /// Borrow the wrapped card reference.
    #[must_use]
    pub fn as_card_ref(&self) -> Option<&CardRef> {
        self.0.as_card_ref()
    }

    fn kind_mismatch(kind: &CardKind) -> WyrdError {
        WyrdError::ValaEvalRefKindMismatch {
            message: format!("dataset_ref must reference a Data card; got {kind:?}"),
            details: serde_json::json!({
                "field": "dataset",
                "expected": "Data",
                "got": format!("{kind:?}"),
            }),
        }
    }
}

/// Sampling policy applied at the record-ingest boundary.
///
/// `vala-eval` enforces the policy when records are admitted for evaluation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum EvalSampling {
    /// Probabilistic selection.
    Ratio {
        /// Selection ratio in `[0.0, 1.0]`.
        ratio: f64,
    },
    /// Deterministic 1-in-N selection by hashing a stable key path.
    DeterministicByHash {
        /// JSONPath selecting the stable key to hash.
        key_path: JsonPath,
        /// Positive modulus for bucket assignment.
        modulus: u32,
        /// Selected bucket. Must be less than `modulus`.
        bucket: u32,
    },
    /// Every Nth record.
    EveryNth {
        /// Positive interval. Must be at least 1.
        n: u32,
    },
}

impl EvalSampling {
    /// Validate sampling bounds.
    ///
    /// # Errors
    /// Returns [`WyrdError::Validation`] when a ratio is out of range, a
    /// deterministic hash modulus is zero, a bucket is outside the modulus, or
    /// an every-Nth interval is zero.
    pub fn validate(&self) -> Result<(), WyrdError> {
        match self {
            EvalSampling::Ratio { ratio } => {
                if !(0.0..=1.0).contains(ratio) || ratio.is_nan() {
                    return Err(WyrdError::Validation {
                        message: format!("eval_sampling.ratio must be in [0.0, 1.0]; got {ratio}"),
                        details: serde_json::Value::Null,
                    });
                }
            }
            EvalSampling::DeterministicByHash {
                modulus, bucket, ..
            } => {
                if *modulus == 0 {
                    return Err(WyrdError::Validation {
                        message: "eval_sampling.modulus must be >= 1".to_string(),
                        details: serde_json::Value::Null,
                    });
                }
                if bucket >= modulus {
                    return Err(WyrdError::Validation {
                        message: format!(
                            "eval_sampling.bucket {bucket} must be < modulus {modulus}"
                        ),
                        details: serde_json::Value::Null,
                    });
                }
            }
            EvalSampling::EveryNth { n } => {
                if *n == 0 {
                    return Err(WyrdError::Validation {
                        message: "eval_sampling.n must be >= 1".to_string(),
                        details: serde_json::Value::Null,
                    });
                }
            }
        }
        Ok(())
    }
}