pub mod drift;
pub mod lifecycle;
#[cfg(test)]
mod tests;
use arrow_schema::DataType;
use serde::Serialize;
use serde_json::{Value, json};
use wyrd_spec::error::WyrdError;
use wyrd_spec::ids::{CardUid, GroupId};
use wyrd_spec::reference::CardRef;
use wyrd_spec::vala::ids::RunId;
use wyrd_spec::vala::ids::SessionId;
use wyrd_spec::verification::execution::VerificationResult;
use crate::bifrost::{Correlation, WriterTable};
use crate::state::{BoundVerifier, WyrdState};
use crate::verify::{Execution, Inputs, Projected, RunEvidence, Submission, Target};
pub use lifecycle::DRIFT_OBSERVATIONS_TABLE;
const DATASETS_PREFIX: &str = "vala.datasets.";
tokio::task_local! {
static RUN_SCOPE: (String, String);
}
#[must_use]
pub fn current_run_scope() -> Option<(String, String)> {
RUN_SCOPE.try_with(Clone::clone).ok()
}
#[derive(Debug, Clone)]
#[expect(
clippy::struct_field_names,
reason = "`run_id` is the wire name of the invocation identity"
)]
pub struct Run {
state: WyrdState,
run_id: RunId,
alias: String,
subject: CardRef,
}
impl Run {
pub fn scope<F: Future>(&self, future: F) -> impl Future<Output = F::Output> {
RUN_SCOPE.scope(
(
self.subject_uid().as_str().to_owned(),
self.run_id.as_str().to_owned(),
),
future,
)
}
pub(crate) fn new(state: WyrdState, alias: String, subject: CardRef) -> Self {
Self {
state,
run_id: RunId::new(),
alias,
subject,
}
}
#[must_use]
pub fn run_id(&self) -> &RunId {
&self.run_id
}
#[must_use]
pub fn alias(&self) -> &str {
&self.alias
}
#[must_use]
#[cfg(feature = "internal")]
pub fn subject(&self) -> &CardRef {
&self.subject
}
pub fn for_card(&self, alias: &str) -> Result<Self, WyrdError> {
Ok(Self {
state: self.state.clone(),
run_id: self.run_id.clone(),
alias: alias.to_owned(),
subject: self.state.card_ref(alias)?.clone(),
})
}
#[must_use]
pub fn observe(&self) -> Observe<'_> {
Observe {
run: self,
group_id: None,
}
}
fn correlation(&self) -> Correlation {
Correlation {
card_uid: Some(self.subject_uid().clone()),
run_id: Some(self.run_id.clone()),
}
}
#[must_use]
pub fn subject_uid(&self) -> &CardUid {
self.subject
.uid
.as_ref()
.expect("every hydrated Card carries its UID")
}
}
#[derive(Debug)]
pub struct Observe<'a> {
run: &'a Run,
group_id: Option<GroupId>,
}
impl Observe<'_> {
fn drift_value(
&self,
features: &Value,
session_id: Option<SessionId>,
) -> Result<(), WyrdError> {
let record = drift::observation(features, session_id)?;
let started = self.run.state.started_bifrost()?;
started.bifrost.insert_observation(
&started.drift,
drift::rows(&record)?,
self.run.correlation(),
)?;
Ok(())
}
pub async fn record<T: Serialize>(&self, table: &str, row: &T) -> Result<(), WyrdError> {
self.record_value(table, &to_value(row, "record row")?)
.await
}
#[cfg(feature = "internal")]
pub async fn record_json(&self, table: &str, json: &str) -> Result<(), WyrdError> {
self.record_value(table, &parse_json(json, "record row")?)
.await
}
async fn record_value(&self, table: &str, row: &Value) -> Result<(), WyrdError> {
if !table.starts_with(DATASETS_PREFIX) || table[DATASETS_PREFIX.len()..].contains('.') {
return Err(invalid_observation(
"observe.record writes only registered `vala.datasets.<name>` tables",
json!({ "table": table }),
));
}
let started = self.run.state.started_bifrost()?;
let destination = started.bifrost.resolve_writer_table(table).await?;
started.bifrost.insert_observation(
&destination,
vec![row_bytes(row)?],
self.run.correlation(),
)?;
Ok(())
}
}
impl Observe<'_> {
#[must_use]
pub fn group_id(mut self, group_id: GroupId) -> Self {
self.group_id = Some(group_id);
self
}
#[cfg(feature = "internal")]
pub fn grouped(self, group_id: Option<&str>) -> Result<Self, WyrdError> {
let Some(group_id) = group_id else {
return Ok(self);
};
GroupId::new(group_id)
.map(|group_id| self.group_id(group_id))
.map_err(|error| WyrdError::Validation {
message: format!("group_id is invalid: {error}"),
details: json!({ "field": "group_id", "reason": error.to_string() }),
})
}
pub async fn verify(
&self,
verifier: &str,
inputs: impl Into<Inputs>,
) -> Result<VerificationResult, WyrdError> {
let state = &self.run.state;
let bound = state.bound_verifier(&self.run.subject, verifier)?;
let target = self.target(&bound);
match target.project(inputs.into())? {
Projected::Run(run) => target.execute(state.client()?, run).await,
Projected::Window(_) => Err(WyrdError::VerificationInputUnsupported {
message:
"a scheduled-window Verifier judges recorded samples; record them with submit"
.to_owned(),
details: json!({ "verifier": verifier }),
}),
}
}
pub async fn submit(
&self,
verifier: &str,
inputs: impl Into<Inputs>,
) -> Result<Submission, WyrdError> {
match self.record_or_stage(verifier, inputs)? {
None => Ok(Submission::Recorded),
Some(staged) => Ok(Submission::Queued(self.enqueue_run(staged).await?)),
}
}
#[cfg(feature = "internal")]
pub fn stage(
&self,
verifier: &str,
inputs: impl Into<Inputs>,
) -> Result<Option<StagedRun>, WyrdError> {
self.record_or_stage(verifier, inputs)
}
fn record_or_stage(
&self,
verifier: &str,
inputs: impl Into<Inputs>,
) -> Result<Option<StagedRun>, WyrdError> {
let bound = self.run.state.bound_verifier(&self.run.subject, verifier)?;
match self.target(&bound).project(inputs.into())? {
Projected::Run(evidence) => Ok(Some(StagedRun {
verifier: verifier.to_owned(),
group_id: self.group_id.clone(),
evidence,
})),
Projected::Window(features) => {
self.drift_value(&features, None)?;
Ok(None)
}
}
}
#[cfg(feature = "internal")]
pub async fn enqueue_staged(&self, staged: StagedRun) -> Result<Execution, WyrdError> {
self.enqueue_run(staged).await
}
async fn enqueue_run(&self, staged: StagedRun) -> Result<Execution, WyrdError> {
let state = &self.run.state;
let bound = state.bound_verifier(&self.run.subject, &staged.verifier)?;
let mut target = self.target(&bound);
target.group_id = staged.group_id;
target.enqueue(state.client()?, staged.evidence).await
}
fn target<'a>(&self, bound: &'a BoundVerifier) -> Target<'a> {
Target {
spec: &bound.spec,
verifier_card_uid: bound.verifier_card_uid.clone(),
card_uid: bound.card_uid.clone(),
run_id: self.run.run_id.clone(),
group_id: self.group_id.clone(),
}
}
}
#[derive(Debug)]
pub struct StagedRun {
verifier: String,
group_id: Option<GroupId>,
evidence: RunEvidence,
}
fn row_bytes(row: &Value) -> Result<Vec<u8>, WyrdError> {
serde_json::to_vec(row).map_err(|error| {
invalid_observation(
"projected row is not serializable",
json!({ "source": error.to_string() }),
)
})
}
fn to_value<T: Serialize>(value: &T, field: &str) -> Result<Value, WyrdError> {
serde_json::to_value(value).map_err(|error| {
invalid_observation(
"observation input is not serializable as JSON",
json!({ "field": field, "source": error.to_string() }),
)
})
}
#[cfg(feature = "internal")]
fn parse_json(text: &str, field: &str) -> Result<Value, WyrdError> {
serde_json::from_str(text).map_err(|error| {
invalid_observation(
"observation input is not valid JSON",
json!({ "field": field, "source": error.to_string() }),
)
})
}
type ProjectedColumn = (&'static str, DataType, bool);
fn require_projection(table: &WriterTable, expected: &[ProjectedColumn]) -> Result<(), WyrdError> {
let schema = table.user_schema();
let fields = schema.fields();
let exact = fields.len() == expected.len()
&& fields
.iter()
.zip(expected)
.all(|(field, (name, data_type, nullable))| {
field.name() == name
&& field.data_type() == data_type
&& field.is_nullable() == *nullable
});
if exact {
return Ok(());
}
let expected: Vec<String> = expected
.iter()
.map(|(name, data_type, nullable)| column_label(name, data_type, *nullable))
.collect();
let described: Vec<String> = fields
.iter()
.map(|field| column_label(field.name(), field.data_type(), field.is_nullable()))
.collect();
Err(invalid_observation(
"fixed observation table declares an incompatible authored schema",
json!({ "table": table.fqn(), "expected": expected, "described": described }),
))
}
fn column_label(name: &str, data_type: &DataType, nullable: bool) -> String {
let nullability = if nullable { "null" } else { "not null" };
format!("{name}: {data_type} {nullability}")
}
fn invalid_observation(message: &str, details: Value) -> WyrdError {
WyrdError::SdkInvalidObservation {
message: message.to_owned(),
details,
}
}