use std::collections::BTreeMap;
use std::fmt;
use std::io;
use std::ops::Add;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use web_time::{SystemTime, UNIX_EPOCH};
use henad_compute::runtime_info::{HostInfo, RuntimeInfo};
use henad_core::explore::outcome::RunStatus;
use henad_core::explore::plan::Shard;
use henad_core::explore::search::pse::PatternSpaceSettings;
use henad_core::provenance::{BuildInfo, ModelSource};
pub const FORMAT: &str = "henad-explore";
pub const FORMAT_VERSION: u64 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
pub format: String,
pub format_version: u64,
pub mode: ManifestMode,
pub status: ManifestStatus,
pub engine: RecordedBuild,
pub model: ManifestModel,
pub spec: Value,
pub spec_source: ManifestSpecSource,
pub argv: Vec<String>,
pub plan: ManifestPlan,
pub seeds: ManifestSeeds,
pub columns: ManifestColumns,
pub shard: ManifestShard,
pub execution: ManifestExecution,
pub runtime: ManifestRuntime,
pub timestamps: ManifestTimestamps,
pub sessions: Vec<ManifestSession>,
pub results: Option<ResultCounts>,
pub merged_shards: Option<Vec<String>>,
#[serde(default)]
pub search: Option<ManifestSearch>,
}
impl Manifest {
pub fn read(path: &Path) -> Result<Self, ManifestError> {
let text = std::fs::read_to_string(path).map_err(|source| ManifestError::Read {
path: path.to_owned(),
source,
})?;
Self::parse(&text, path)
}
pub fn parse(text: &str, path: &Path) -> Result<Self, ManifestError> {
let manifest: Self = serde_json::from_str(text).map_err(|source| ManifestError::Parse {
path: path.to_owned(),
source,
})?;
if manifest.format != FORMAT || manifest.format_version != FORMAT_VERSION {
return Err(ManifestError::Format {
path: path.to_owned(),
format: manifest.format,
format_version: manifest.format_version,
});
}
Ok(manifest)
}
pub fn finish(&mut self, status: ManifestStatus, results: ResultCounts, finished_unix_ms: u64) {
self.status = status;
self.results = Some(results);
self.timestamps.finished_unix_ms = Some(finished_unix_ms);
self.timestamps.finished = Some(rfc3339(finished_unix_ms));
if let Some(session) = self.sessions.last_mut() {
session.ran = results.rows.saturating_sub(session.skipped);
}
}
pub fn session_build(&self, session: &ManifestSession, role: BuildRole) -> Option<RecordedBuild> {
match role {
BuildRole::Engine => Some(session.engine.clone().unwrap_or_else(|| RecordedBuild {
package: self.engine.package.clone(),
version: self.engine.version.clone(),
commit: session.commit.clone(),
commit_date: if session.commit == self.engine.commit {
self.engine.commit_date.clone()
} else {
String::new()
},
dirty: None,
source_hash: None,
debug_build: self.engine.debug_build,
type_path: None,
crate_hashes: BTreeMap::new(),
crate_versions: BTreeMap::new(),
})),
BuildRole::Model => session.model_source.clone(),
}
}
pub fn recorded_builds(&self, role: BuildRole) -> Vec<RecordedBuild> {
let uncredited = matches!(self.status, ManifestStatus::Running | ManifestStatus::Failed);
let last = self.sessions.len().saturating_sub(1);
let mut builds: Vec<RecordedBuild> = Vec::new();
for build in self
.sessions
.iter()
.enumerate()
.filter(|&(index, session)| session.ran > 0 || session.host.is_none() || (uncredited && index == last))
.filter_map(|(_, session)| self.session_build(session, role))
{
if !builds.iter().any(|known| known.reads_as(&build)) {
builds.push(build);
}
}
builds
}
pub(crate) fn record_session_engines(&mut self) {
let builds: Vec<Option<RecordedBuild>> = self
.sessions
.iter()
.map(|session| self.session_build(session, BuildRole::Engine))
.collect();
for (session, build) in self.sessions.iter_mut().zip(builds) {
if session.engine.is_none() {
session.engine = build;
}
}
}
pub fn fail(&mut self, finished_unix_ms: u64) {
self.status = ManifestStatus::Failed;
self.results = None;
self.timestamps.finished_unix_ms = Some(finished_unix_ms);
self.timestamps.finished = Some(rfc3339(finished_unix_ms));
}
}
#[derive(Debug)]
pub enum ManifestError {
Read {
path: PathBuf,
source: io::Error,
},
Parse {
path: PathBuf,
source: serde_json::Error,
},
Format {
path: PathBuf,
format: String,
format_version: u64,
},
}
impl fmt::Display for ManifestError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read { path, .. } => write!(f, "cannot read '{}'", path.display()),
Self::Parse { path, .. } => write!(f, "'{}' is not a sweep manifest", path.display()),
Self::Format {
path,
format,
format_version,
} => write!(
f,
"'{}' has format '{format}' version {format_version}, expected '{FORMAT}' version \
{FORMAT_VERSION}",
path.display()
),
}
}
}
impl std::error::Error for ManifestError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Read { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
Self::Format { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManifestMode {
Sweep,
Search,
}
impl ManifestMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Sweep => "sweep",
Self::Search => "search",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestSearch {
pub algorithm: String,
pub search_hash: String,
pub max_evaluations: u64,
pub batch_size: usize,
pub search_seed: u64,
pub watched_columns: Vec<String>,
pub evaluations: u64,
pub batch_count: u64,
pub best_candidate_id: Option<u64>,
pub best_objective: Option<f64>,
pub filled_cells: Option<u64>,
#[serde(default)]
pub axis_ranges: Option<ManifestAxisRanges>,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ManifestAxisRanges {
pub x_min: f64,
pub x_max: f64,
pub y_min: f64,
pub y_max: f64,
}
impl ManifestAxisRanges {
pub fn of(settings: &PatternSpaceSettings) -> Option<Self> {
let ((x_min, x_max), (y_min, y_max)) = settings.x_axis.range().zip(settings.y_axis.range())?;
Some(Self {
x_min,
x_max,
y_min,
y_max,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManifestStatus {
Running,
Complete,
Aborted,
Failed,
Incomplete,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordedBuild {
#[serde(rename = "name")]
pub package: String,
pub version: String,
pub commit: String,
pub commit_date: String,
#[serde(default)]
pub dirty: Option<bool>,
#[serde(default, with = "hex_hash")]
pub source_hash: Option<u64>,
pub debug_build: bool,
#[serde(default)]
pub type_path: Option<String>,
#[serde(default, with = "hex_hash_map")]
pub crate_hashes: BTreeMap<String, u64>,
#[serde(default)]
pub crate_versions: BTreeMap<String, String>,
}
impl From<&BuildInfo> for RecordedBuild {
fn from(build: &BuildInfo) -> Self {
Self {
package: build.package().to_owned(),
version: build.version().to_owned(),
commit: build.commit().to_owned(),
commit_date: build.commit_date().to_owned(),
dirty: build.dirty(),
source_hash: build.source_hash(),
debug_build: build.debug_build(),
type_path: None,
crate_hashes: BTreeMap::new(),
crate_versions: BTreeMap::new(),
}
}
}
impl From<&ModelSource> for RecordedBuild {
fn from(source: &ModelSource) -> Self {
let mut build = source.build().map_or_else(
|| Self {
package: String::new(),
version: String::new(),
commit: String::new(),
commit_date: String::new(),
dirty: None,
source_hash: None,
debug_build: cfg!(debug_assertions),
type_path: None,
crate_hashes: BTreeMap::new(),
crate_versions: BTreeMap::new(),
},
Self::from,
);
build.type_path = Some(source.type_path().to_owned());
build
}
}
pub const ENGINE_PACKAGE: &str = "henad";
impl RecordedBuild {
pub fn engine() -> Self {
let engine = crate::ENGINE_BUILD;
let compute = henad_compute::__COMPUTE_BUILD;
let mut build = Self::from(&engine);
build.package = ENGINE_PACKAGE.to_owned();
let versions = [
("henad-core", henad_core::__VERSION),
("henad-build", crate::STAMP_VERSION),
("henad-compute", compute.version()),
("henad-explore", engine.version()),
];
build.crate_versions = versions
.into_iter()
.filter(|(_, version)| !version.is_empty())
.map(|(package, version)| (package.to_owned(), version.to_owned()))
.collect();
let hashes = [
("henad-compute", compute.source_hash()),
("henad-explore", u64::from_str_radix(crate::CRATE_HASH, 16).ok()),
];
build.crate_hashes = hashes
.into_iter()
.filter_map(|(package, hash)| Some((package.to_owned(), hash?)))
.collect();
build
}
pub fn same_build(&self, other: &Self) -> bool {
if self.package != other.package || self.version != other.version {
return false;
}
if differs_in(&self.crate_versions, &other.crate_versions)
|| differs_in(&self.crate_hashes, &other.crate_hashes)
{
return false;
}
if self.is_clean_commit() && other.is_clean_commit() {
return self.commit == other.commit;
}
matches!((self.source_hash, other.source_hash), (Some(left), Some(right)) if left == right)
}
pub fn reads_as(&self, other: &Self) -> bool {
self.same_build(other)
|| (!self.is_identified()
&& !other.is_identified()
&& self.package == other.package
&& self.version == other.version
&& self.type_path == other.type_path
&& self.crate_versions == other.crate_versions
&& self.crate_hashes == other.crate_hashes)
}
fn is_clean_commit(&self) -> bool {
!self.commit.is_empty()
&& match self.dirty {
Some(dirty) => !dirty,
None => self.source_hash.is_none(),
}
}
pub fn is_identified(&self) -> bool {
!self.commit.is_empty() || self.source_hash.is_some()
}
pub fn describe(&self) -> String {
let mut parts = Vec::new();
if !self.commit.is_empty() {
parts.push(self.commit.clone());
}
if self.dirty == Some(true) {
parts.push("modified".to_owned());
}
if (self.commit.is_empty() || self.dirty == Some(true))
&& let Some(hash) = self.source_hash
{
parts.push(format!("sources {hash:016x}"));
}
let package = if self.package.is_empty() {
"an unknown package"
} else {
&self.package
};
let mut text = format!("{package} {}", self.version).trim_end().to_owned();
if parts.is_empty() {
text.push_str(" (unidentified)");
} else {
text.push_str(&format!(" ({})", parts.join(", ")));
}
text
}
pub(crate) fn crate_difference(&self, other: &Self) -> Option<String> {
for (package, version) in &self.crate_versions {
if let Some(other_version) = other.crate_versions.get(package)
&& other_version != version
{
return Some(format!("{package} {version} against {other_version}"));
}
}
self.crate_hashes.iter().find_map(|(package, hash)| {
other
.crate_hashes
.get(package)
.filter(|other_hash| *other_hash != hash)
.map(|_| format!("the sources of {package}"))
})
}
}
fn differs_in<T: PartialEq>(left: &BTreeMap<String, T>, right: &BTreeMap<String, T>) -> bool {
left.iter()
.any(|(package, value)| right.get(package).is_some_and(|other_value| other_value != value))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BuildRole {
Engine,
Model,
}
impl BuildRole {
pub fn as_str(self) -> &'static str {
match self {
Self::Engine => "engine",
Self::Model => "model",
}
}
}
mod hex_hash {
use serde::{Deserialize as _, Deserializer, Serializer};
#[expect(clippy::ref_option, reason = "serde hands a field to its writer by reference")]
pub(super) fn serialize<S: Serializer>(hash: &Option<u64>, serializer: S) -> Result<S::Ok, S::Error> {
match hash {
Some(hash) => serializer.serialize_str(&format!("{hash:016x}")),
None => serializer.serialize_none(),
}
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<u64>, D::Error> {
Option::<String>::deserialize(deserializer)?
.map(|text| u64::from_str_radix(&text, 16).map_err(serde::de::Error::custom))
.transpose()
}
}
mod hex_hash_map {
use std::collections::BTreeMap;
use serde::{Deserialize as _, Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(hashes: &BTreeMap<String, u64>, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_map(hashes.iter().map(|(package, hash)| (package, format!("{hash:016x}"))))
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<BTreeMap<String, u64>, D::Error> {
BTreeMap::<String, String>::deserialize(deserializer)?
.into_iter()
.map(|(package, text)| {
let hash = u64::from_str_radix(&text, 16).map_err(serde::de::Error::custom)?;
Ok((package, hash))
})
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestModel {
pub id: String,
pub name: String,
pub backend: String,
pub schema_hash: String,
pub schema: Value,
#[serde(default = "default_true")]
pub replays_exactly: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestSpecSource {
pub path: Option<String>,
pub toml: Option<String>,
pub tables: Vec<ManifestDesignTable>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestDesignTable {
pub path: String,
pub fnv1a64: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestPlan {
pub plan_hash: String,
pub results_fingerprint: String,
pub configs: Option<u64>,
pub replicates: u64,
pub runs: u64,
pub blocks: Vec<ManifestBlock>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestBlock {
pub design: String,
pub configs: u64,
pub design_seed: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestSeeds {
pub root: u64,
pub scheme: String,
pub formula: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestColumns {
pub stats: Vec<String>,
pub reducers: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestShard {
pub index: u64,
pub count: u64,
}
impl From<Shard> for ManifestShard {
fn from(shard: Shard) -> Self {
Self {
index: shard.index(),
count: shard.count(),
}
}
}
impl ManifestShard {
pub fn to_shard(self) -> Option<Shard> {
Shard::new(self.index, self.count).ok()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestExecution {
pub backend: String,
pub concurrency: String,
pub cpu_lanes: usize,
pub threads_per_lane: usize,
pub gpu_tracks: usize,
pub projected_bytes: u64,
pub memory_budget: Option<u64>,
pub gpu_memory_budget: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestRuntime {
pub os: String,
pub arch: String,
pub logical_cpus: Option<usize>,
pub worker_threads: Option<usize>,
pub adapter: Option<String>,
pub adapter_backend: Option<String>,
pub adapter_type: Option<String>,
pub driver: Option<String>,
pub limits: Option<ManifestLimits>,
}
impl ManifestRuntime {
pub fn new(runtime: Option<&RuntimeInfo>) -> Self {
let collected;
let host = if let Some(runtime) = runtime {
&runtime.host
} else {
collected = HostInfo::collect();
&collected
};
Self {
os: host.os.to_owned(),
arch: host.arch.to_owned(),
logical_cpus: host.logical_cpus,
worker_threads: host.worker_threads,
adapter: runtime.map(|runtime| runtime.adapter.name.clone()),
adapter_backend: runtime.map(|runtime| runtime.adapter.backend.to_string()),
adapter_type: runtime.map(|runtime| format!("{:?}", runtime.adapter.device_type)),
driver: runtime.map(|runtime| runtime.adapter.driver.clone()),
limits: runtime.map(|runtime| ManifestLimits {
max_buffer_size: runtime.granted.max_buffer_size,
max_storage_buffer_binding_size: runtime.granted.max_storage_buffer_binding_size,
max_texture_dimension_2d: runtime.granted.max_texture_dimension_2d,
}),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestLimits {
pub max_buffer_size: u64,
pub max_storage_buffer_binding_size: u64,
pub max_texture_dimension_2d: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestTimestamps {
pub started_unix_ms: u64,
pub started: String,
pub finished_unix_ms: Option<u64>,
pub finished: Option<String>,
}
impl ManifestTimestamps {
pub fn started_at(started_unix_ms: u64) -> Self {
Self {
started_unix_ms,
started: rfc3339(started_unix_ms),
finished_unix_ms: None,
finished: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestSession {
pub started: String,
pub commit: String,
pub skipped: u64,
pub ran: u64,
#[serde(default)]
pub engine: Option<RecordedBuild>,
#[serde(default)]
pub host: Option<RecordedBuild>,
#[serde(default)]
pub model_source: Option<RecordedBuild>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResultCounts {
pub rows: u64,
pub ok: u64,
pub non_finite: u64,
pub failed: u64,
}
impl ResultCounts {
pub fn count(&mut self, status: RunStatus) {
self.rows += 1;
match status {
RunStatus::Ok => self.ok += 1,
RunStatus::NonFinite => self.non_finite += 1,
RunStatus::Panicked
| RunStatus::GpuError
| RunStatus::Refused
| RunStatus::ShapeError
| RunStatus::TimedOut => {
self.failed += 1;
}
}
}
}
impl Add for ResultCounts {
type Output = Self;
fn add(self, other: Self) -> Self {
Self {
rows: self.rows + other.rows,
ok: self.ok + other.ok,
non_finite: self.non_finite + other.non_finite,
failed: self.failed + other.failed,
}
}
}
pub(crate) fn now_unix_ms() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |since_epoch| {
u64::try_from(since_epoch.as_millis()).unwrap_or(u64::MAX)
})
}
pub(crate) fn rfc3339(unix_ms: u64) -> String {
let seconds = unix_ms / 1000;
let (year, month, day) = civil_date(seconds / 86_400);
let second_of_day = seconds % 86_400;
format!(
"{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.{:03}Z",
second_of_day / 3600,
second_of_day % 3600 / 60,
second_of_day % 60,
unix_ms % 1000
)
}
fn civil_date(days: u64) -> (u64, u64, u64) {
let shifted = days + 719_468;
let era = shifted / 146_097;
let day_of_era = shifted % 146_097;
let year_of_era = (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_from_march = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_from_march + 2) / 5 + 1;
let month = if month_from_march < 10 {
month_from_march + 3
} else {
month_from_march - 9
};
let year = year_of_era + era * 400 + u64::from(month <= 2);
(year, month, day)
}
#[cfg(test)]
mod tests {
use std::path::Path;
use henad_core::explore::outcome::RunStatus;
use henad_core::explore::spec::SweepSpec;
use serde_json::Value;
use super::{BuildRole, Manifest, ManifestStatus, RecordedBuild, ResultCounts, rfc3339};
use crate::exec::Concurrency;
use crate::output::MANIFEST_FILE;
use crate::tests::support::{ScratchDir, entry, sweep};
#[test]
fn a_sweep_manifest_writes_its_search_as_null() {
let scratch = ScratchDir::new("sweep-manifest");
let mut spec = SweepSpec::new("game_of_life");
spec.fixed = vec![
("grid_width".to_owned(), "8".to_owned()),
("grid_height".to_owned(), "8".to_owned()),
];
spec.run.steps = 2;
sweep(
&entry("game_of_life", None),
None,
&spec,
scratch.path(),
Concurrency::Auto,
);
let text = std::fs::read_to_string(scratch.path().join(MANIFEST_FILE)).expect("the manifest is written");
let mut json: Value = serde_json::from_str(&text).expect("the manifest is JSON");
assert_eq!(json.get("search"), Some(&Value::Null));
assert_eq!(json.get("merged_shards"), Some(&Value::Null));
let manifest = Manifest::parse(&text, Path::new(MANIFEST_FILE)).expect("the manifest reads back");
assert_eq!(manifest.search, None);
json.as_object_mut().expect("a JSON object").remove("search");
let without_key: Manifest = serde_json::from_value(json).expect("a manifest without the key reads");
assert_eq!(without_key, manifest);
}
fn one_run_manifest(dir: &Path) -> Manifest {
let mut spec = SweepSpec::new("game_of_life");
spec.fixed = vec![
("grid_width".to_owned(), "8".to_owned()),
("grid_height".to_owned(), "8".to_owned()),
];
spec.run.steps = 2;
sweep(&entry("game_of_life", None), None, &spec, dir, Concurrency::Auto);
Manifest::read(&dir.join(MANIFEST_FILE)).expect("the manifest reads back")
}
#[test]
fn a_session_that_wrote_no_run_lists_no_build() {
let scratch = ScratchDir::new("session-without-runs");
let mut manifest = one_run_manifest(scratch.path());
let mut older = RecordedBuild::engine();
older.version = "0.1.0".to_owned();
let mut idle = manifest.sessions[0].clone();
(idle.skipped, idle.ran, idle.engine) = (1, 0, Some(older.clone()));
manifest.sessions.push(idle);
assert_eq!(
manifest.recorded_builds(BuildRole::Engine),
[RecordedBuild::engine()],
"a resume that found every run written ran nothing"
);
manifest.status = ManifestStatus::Running;
assert_eq!(
manifest.recorded_builds(BuildRole::Engine),
[RecordedBuild::engine(), older.clone()],
"the last session of a running manifest waits for the next resume to credit it"
);
manifest.status = ManifestStatus::Complete;
if let Some(last) = manifest.sessions.last_mut() {
last.host = None;
}
assert_eq!(
manifest.recorded_builds(BuildRole::Engine),
[RecordedBuild::engine(), older],
"a 0.2 session could end without its runs credited"
);
}
#[test]
fn unidentified_builds_alike_are_listed_once() {
let scratch = ScratchDir::new("unidentified-builds");
let mut manifest = one_run_manifest(scratch.path());
let mut unidentified = RecordedBuild::engine();
(unidentified.commit, unidentified.dirty, unidentified.source_hash) = (String::new(), None, None);
manifest.sessions[0].engine = Some(unidentified.clone());
manifest.sessions.push(manifest.sessions[0].clone());
assert!(
!unidentified.same_build(&unidentified),
"neither can be told apart from the other"
);
assert_eq!(manifest.recorded_builds(BuildRole::Engine), [unidentified.clone()]);
let mut other_version = unidentified.clone();
other_version.version = "0.1.0".to_owned();
manifest.sessions[1].engine = Some(other_version.clone());
assert_eq!(
manifest.recorded_builds(BuildRole::Engine),
[unidentified, other_version]
);
}
#[test]
fn two_unidentified_builds_are_never_the_same() {
let mut build = RecordedBuild::engine();
build.commit = String::new();
build.dirty = None;
build.source_hash = None;
assert!(!build.same_build(&build.clone()));
build.source_hash = Some(7);
assert!(build.same_build(&build.clone()));
build.commit = "abc12345".to_owned();
build.dirty = Some(false);
let mut other = build.clone();
other.source_hash = Some(8);
assert!(
build.same_build(&other),
"two clean builds of one commit compare by commit"
);
other.dirty = Some(true);
assert!(!build.same_build(&other), "a dirty side compares by source hash");
other.dirty = None;
assert!(
!build.same_build(&other),
"a commit with an unknown flag and a source hash compares by source hash"
);
other.source_hash = Some(7);
assert!(build.same_build(&other));
let mut commit_alone = build.clone();
(commit_alone.dirty, commit_alone.source_hash) = (None, None);
assert!(
commit_alone.same_build(&build),
"a 0.2 session's commit alone compares by commit"
);
}
#[test]
fn a_recorded_build_writes_its_hashes_in_hexadecimal() {
let mut build = RecordedBuild::engine();
build.source_hash = Some(0xAB);
build.crate_hashes.insert("henad-compute".to_owned(), 0xCD);
let json = serde_json::to_value(&build).expect("a build writes as JSON");
assert_eq!(json["source_hash"], "00000000000000ab");
assert_eq!(json["crate_hashes"]["henad-compute"], "00000000000000cd");
assert_eq!(json["name"], "henad");
let read: RecordedBuild = serde_json::from_value(json).expect("a build reads back");
assert_eq!(read, build);
let block_0_2 = serde_json::json!({
"name": "henad",
"version": "0.2.0",
"commit": "773a7a5b",
"commit_date": "2026-09-29",
"debug_build": false,
});
let read: RecordedBuild = serde_json::from_value(block_0_2).expect("a 0.2 engine block reads");
assert_eq!((read.dirty, read.source_hash, read.type_path), (None, None, None));
assert!(read.crate_hashes.is_empty() && read.crate_versions.is_empty());
}
#[test]
fn timestamps_are_written_in_rfc3339() {
assert_eq!(rfc3339(0), "1970-01-01T00:00:00.000Z");
assert_eq!(rfc3339(951_782_400_000), "2000-02-29T00:00:00.000Z", "a leap day");
assert_eq!(rfc3339(951_868_799_999), "2000-02-29T23:59:59.999Z");
assert_eq!(rfc3339(1_798_704_000_000), "2026-12-31T08:00:00.000Z");
assert_eq!(
rfc3339(4_107_542_400_000),
"2100-03-01T00:00:00.000Z",
"2100 is not a leap year"
);
}
#[test]
fn counts_split_the_rows_by_status() {
let mut counts = ResultCounts::default();
for status in [
RunStatus::Ok,
RunStatus::Ok,
RunStatus::NonFinite,
RunStatus::Panicked,
RunStatus::ShapeError,
] {
counts.count(status);
}
assert_eq!(
counts,
ResultCounts {
rows: 5,
ok: 2,
non_finite: 1,
failed: 2
}
);
}
}