use chrono::Utc;
use futures::executor;
use serde::{Deserialize, Serialize};
#[cfg(feature = "plot-core")]
use serde_json::Value;
use crate::replay::limits::ReplayLimits;
use crate::runtime_error::{replay_error, replay_error_with_source, ReplayErrorKind};
use crate::RuntimeError;
const SCENE_SCHEMA_VERSION: u32 = 1;
const SCENE_KIND: &str = "figure-scene";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FigureScenePayload {
schema_version: u32,
kind: String,
created_at: String,
figure: runmat_plot::event::FigureScene,
}
#[cfg(feature = "plot-core")]
pub fn encode_figure_scene_payload(
scene: &runmat_plot::event::FigureScene,
) -> Result<Vec<u8>, RuntimeError> {
encode_figure_scene_payload_with_limits(scene, ReplayLimits::default())
}
#[cfg(feature = "plot-core")]
pub fn encode_figure_scene_payload_with_limits(
scene: &runmat_plot::event::FigureScene,
limits: ReplayLimits,
) -> Result<Vec<u8>, RuntimeError> {
if scene.plots.len() > limits.max_scene_plots {
return Err(replay_error(
ReplayErrorKind::ImportRejected,
format!(
"figure scene contains {} plots, exceeding limit {}",
scene.plots.len(),
limits.max_scene_plots
),
));
}
let payload = FigureScenePayload {
schema_version: SCENE_SCHEMA_VERSION,
kind: SCENE_KIND.to_string(),
created_at: Utc::now().to_rfc3339(),
figure: scene.clone(),
};
let encoded = serde_json::to_vec(&payload).map_err(|err| {
replay_error_with_source(
ReplayErrorKind::DecodeFailed,
"failed to encode figure replay payload",
err,
)
})?;
if encoded.len() > limits.max_scene_payload_bytes {
return Err(replay_error(
ReplayErrorKind::PayloadTooLarge,
format!(
"figure scene payload is {} bytes, exceeding limit {}",
encoded.len(),
limits.max_scene_payload_bytes
),
));
}
Ok(encoded)
}
#[cfg(feature = "plot-core")]
pub fn decode_figure_scene_payload(
bytes: &[u8],
) -> Result<runmat_plot::event::FigureScene, RuntimeError> {
decode_figure_scene_payload_with_limits(bytes, ReplayLimits::default())
}
#[cfg(feature = "plot-core")]
pub fn decode_figure_scene_payload_with_limits(
bytes: &[u8],
limits: ReplayLimits,
) -> Result<runmat_plot::event::FigureScene, RuntimeError> {
executor::block_on(decode_figure_scene_payload_with_limits_async(bytes, limits))
}
#[cfg(feature = "plot-core")]
pub async fn decode_figure_scene_payload_async(
bytes: &[u8],
) -> Result<runmat_plot::event::FigureScene, RuntimeError> {
decode_figure_scene_payload_with_limits_async(bytes, ReplayLimits::default()).await
}
#[cfg(feature = "plot-core")]
pub async fn decode_figure_scene_payload_with_limits_async(
bytes: &[u8],
limits: ReplayLimits,
) -> Result<runmat_plot::event::FigureScene, RuntimeError> {
if bytes.len() > limits.max_scene_payload_bytes {
return Err(replay_error(
ReplayErrorKind::PayloadTooLarge,
format!(
"figure scene payload is {} bytes, exceeding limit {}",
bytes.len(),
limits.max_scene_payload_bytes
),
));
}
let mut payload_json: Value = serde_json::from_slice(bytes).map_err(|err| {
replay_error_with_source(
ReplayErrorKind::DecodeFailed,
"failed to decode figure replay payload",
err,
)
})?;
hydrate_scene_data_refs_async(&mut payload_json).await?;
let payload: FigureScenePayload = serde_json::from_value(payload_json).map_err(|err| {
replay_error_with_source(
ReplayErrorKind::DecodeFailed,
format!("failed to decode hydrated figure replay payload: {err}"),
err,
)
})?;
if payload.schema_version != SCENE_SCHEMA_VERSION {
return Err(replay_error(
ReplayErrorKind::UnsupportedSchema,
format!(
"unsupported figure replay schema version {}",
payload.schema_version
),
));
}
if payload.kind != SCENE_KIND {
return Err(replay_error(
ReplayErrorKind::ImportRejected,
format!("unexpected replay payload kind '{}'", payload.kind),
));
}
if payload.figure.plots.len() > limits.max_scene_plots {
return Err(replay_error(
ReplayErrorKind::ImportRejected,
format!(
"figure scene contains {} plots, exceeding limit {}",
payload.figure.plots.len(),
limits.max_scene_plots
),
));
}
Ok(payload.figure)
}
#[cfg(feature = "plot-core")]
async fn hydrate_scene_data_refs_async(payload: &mut Value) -> Result<(), RuntimeError> {
let Some(plots) = payload
.get_mut("figure")
.and_then(Value::as_object_mut)
.and_then(|figure| figure.get_mut("plots"))
.and_then(Value::as_array_mut)
else {
return Ok(());
};
for plot in plots.iter_mut() {
let Some(kind) = plot.get("kind").and_then(Value::as_str) else {
continue;
};
match kind {
"surface" => {
hydrate_plot_field_async(plot, "x").await?;
hydrate_plot_field_async(plot, "y").await?;
hydrate_plot_field_async(plot, "z").await?;
}
"scatter3" => {
hydrate_plot_field_async(plot, "points").await?;
hydrate_plot_field_async(plot, "colorsRgba").await?;
hydrate_plot_field_async(plot, "pointSizes").await?;
}
_ => {}
}
}
Ok(())
}
#[cfg(feature = "plot-core")]
async fn hydrate_plot_field_async(plot: &mut Value, field: &str) -> Result<(), RuntimeError> {
let Some(obj) = plot.as_object_mut() else {
return Ok(());
};
let Some(value) = obj.get(field).cloned() else {
return Ok(());
};
let Some(data_ref) = parse_data_ref(&value) else {
return Ok(());
};
let payload = read_scene_array_payload_async(&data_ref).await?;
let target_shape = if data_ref.shape.is_empty() {
payload.shape.as_slice()
} else {
data_ref.shape.as_slice()
};
let hydrated = shape_values_to_json(&payload.values, target_shape)?;
obj.insert(field.to_string(), hydrated);
Ok(())
}
async fn read_scene_array_payload_async(
data_ref: &SceneDataRef,
) -> Result<crate::data::DataArrayPayload, RuntimeError> {
let dataset_root = crate::data::dataset_root(&data_ref.dataset_path);
match crate::data::read_manifest_async(&dataset_root).await {
Ok(manifest) => {
let meta = manifest.arrays.get(&data_ref.array).ok_or_else(|| {
replay_error(
ReplayErrorKind::ImportRejected,
format!(
"scene dataset '{}' missing array '{}'",
data_ref.dataset_path, data_ref.array
),
)
})?;
crate::data::read_array_payload_async(&dataset_root, meta)
.await
.map_err(|err| {
replay_error(
ReplayErrorKind::ImportRejected,
format!(
"failed reading scene dataset array '{}.{}': {}",
data_ref.dataset_path, data_ref.array, err
),
)
})
}
Err(manifest_err) => {
if data_ref.chunks.is_empty() {
return Err(replay_error(
ReplayErrorKind::ImportRejected,
format!(
"failed to read scene dataset manifest '{}': {}",
data_ref.dataset_path, manifest_err
),
));
}
let mut values = Vec::new();
let chunk_payloads = read_scene_chunks_bytes_async(&data_ref.chunks, data_ref)
.await
.map_err(|err| {
replay_error(
ReplayErrorKind::ImportRejected,
format!("failed reading scene data chunks: {}", err),
)
})?;
for (chunk, bytes) in data_ref.chunks.iter().zip(chunk_payloads.into_iter()) {
let payload: crate::data::DataArrayPayload = serde_json::from_slice(&bytes)
.map_err(|err| {
replay_error(
ReplayErrorKind::ImportRejected,
format!(
"failed decoding scene data chunk '{}': {}",
chunk
.src
.as_deref()
.or(chunk.artifact_id.as_deref())
.unwrap_or("<unknown>"),
err
),
)
})?;
values.extend(payload.values);
}
Ok(crate::data::DataArrayPayload {
dtype: data_ref.dtype.clone().unwrap_or_else(|| "f64".to_string()),
shape: vec![values.len()],
values,
})
}
}
}
#[cfg(feature = "plot-core")]
#[cfg(test)]
fn read_scene_chunk_bytes(
chunk: &SceneDataChunkRef,
data_ref: &SceneDataRef,
) -> std::io::Result<Vec<u8>> {
let chunks = vec![chunk.clone()];
let mut batch = executor::block_on(read_scene_chunks_bytes_async(&chunks, data_ref))?;
batch
.pop()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "scene chunk missing"))
}
async fn read_scene_chunks_bytes_async(
chunks: &[SceneDataChunkRef],
data_ref: &SceneDataRef,
) -> std::io::Result<Vec<Vec<u8>>> {
let per_chunk_candidates = chunks
.iter()
.map(|chunk| build_scene_chunk_candidates(chunk, data_ref))
.collect::<std::io::Result<Vec<_>>>()?;
let mut unique_paths = Vec::new();
let mut seen = std::collections::HashSet::new();
for candidates in &per_chunk_candidates {
for candidate in candidates {
if seen.insert(candidate.clone()) {
unique_paths.push(candidate.clone());
}
}
}
let request_paths = unique_paths
.iter()
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
let batch = runmat_filesystem::read_many_async(&request_paths).await?;
let mut resolved = std::collections::HashMap::new();
let mut failures = std::collections::HashMap::new();
for (index, entry) in batch.into_iter().enumerate() {
if let Some(path) = unique_paths.get(index) {
let error = entry.error().map(|value| value.to_string());
resolved.insert(path.clone(), entry.into_bytes());
failures.insert(path.clone(), error);
}
}
let mut out = Vec::with_capacity(chunks.len());
for (chunk, candidates) in chunks.iter().zip(per_chunk_candidates.into_iter()) {
let mut found = None;
for candidate in &candidates {
if let Some(Some(bytes)) = resolved.get(candidate) {
found = Some(bytes.clone());
break;
}
}
let bytes = found.ok_or_else(|| {
let attempted = candidates.join(", ");
let failure_details = candidates
.iter()
.filter_map(|candidate| {
failures
.get(candidate)
.and_then(|value| value.as_ref())
.map(|err| format!("{candidate} => {err}"))
})
.collect::<Vec<_>>()
.join(" | ");
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"unable to resolve scene chunk from refs {} (attempted: {}){}",
chunk
.src
.as_deref()
.or(chunk.artifact_id.as_deref())
.unwrap_or("<unknown>"),
attempted,
if failure_details.is_empty() {
String::new()
} else {
format!("; failures: {failure_details}")
}
),
)
})?;
out.push(bytes);
}
Ok(out)
}
fn build_scene_chunk_candidates(
chunk: &SceneDataChunkRef,
data_ref: &SceneDataRef,
) -> std::io::Result<Vec<String>> {
let mut base_paths: Vec<String> = Vec::new();
if let Some(src) = &chunk.src {
let normalized = src.trim();
if !normalized.is_empty() {
base_paths.push(normalized.to_string());
}
}
if let Some(artifact_id) = &chunk.artifact_id {
let artifact_root = artifact_root_from_dataset_path(&data_ref.dataset_path);
base_paths.extend(chunk_paths_from_artifact_id(
artifact_id,
data_ref.dtype.as_deref(),
artifact_root.as_deref(),
));
}
if base_paths.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"empty scene chunk reference",
));
}
let mut candidates: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut push = |candidate: String| {
if candidate.is_empty() {
return;
}
if seen.insert(candidate.clone()) {
candidates.push(candidate);
}
};
for base in &base_paths {
let normalized = normalize_scene_chunk_ref(base);
push(normalized.clone());
if !normalized.starts_with('/') {
push(format!("/{normalized}"));
}
}
Ok(candidates)
}
fn chunk_paths_from_artifact_id(
artifact_id: &str,
dtype: Option<&str>,
artifact_root: Option<&str>,
) -> Vec<String> {
let Some(hash_hex) = artifact_id.strip_prefix("sha256:") else {
return Vec::new();
};
if hash_hex.len() < 2 {
return Vec::new();
}
let prefix = &hash_hex[..2];
let mut suffixes = Vec::new();
match dtype {
Some("f32") => {
suffixes.push("f32.chunk.json");
suffixes.push("f64.chunk.json");
}
_ => {
suffixes.push("f64.chunk.json");
suffixes.push("f32.chunk.json");
}
}
suffixes.push("chunk.json");
suffixes.push("json");
suffixes.push("bin");
let root = normalize_scene_artifact_root(artifact_root.unwrap_or(".artifacts"));
suffixes
.into_iter()
.map(|suffix| format!("{root}/objects/{prefix}/{hash_hex}.{suffix}"))
.collect()
}
fn artifact_root_from_dataset_path(dataset_path: &str) -> Option<String> {
let normalized = normalize_scene_chunk_ref(dataset_path);
let marker = "/datasets/";
let idx = normalized.find(marker)?;
if idx == 0 {
return None;
}
Some(normalized[..idx].to_string())
}
fn normalize_scene_artifact_root(root: &str) -> String {
let normalized = normalize_scene_chunk_ref(root);
if normalized.is_empty() {
return ".artifacts".to_string();
}
normalized.trim_end_matches('/').to_string()
}
fn normalize_scene_chunk_ref(path: &str) -> String {
let normalized = path.trim().replace('\\', "/");
let collapsed = normalized
.split('/')
.filter(|segment| !segment.is_empty() && *segment != ".")
.collect::<Vec<_>>()
.join("/");
collapsed
.trim_start_matches("./")
.trim_start_matches('/')
.to_string()
}
#[cfg(feature = "plot-core")]
fn shape_values_to_json(values: &[f64], shape: &[usize]) -> Result<Value, RuntimeError> {
if shape.is_empty() {
return Ok(Value::from(values.first().copied().unwrap_or(0.0)));
}
if shape.len() == 1 {
return Ok(Value::Array(
values.iter().copied().map(Value::from).collect(),
));
}
if shape.len() == 2 {
let rows = shape[0];
let cols = shape[1];
if rows * cols != values.len() {
return Err(replay_error(
ReplayErrorKind::ImportRejected,
format!(
"scene dataset shape mismatch: {:?} has {} values",
shape,
values.len()
),
));
}
let mut matrix = Vec::with_capacity(rows);
for r in 0..rows {
let mut row = Vec::with_capacity(cols);
for c in 0..cols {
let idx = r + c * rows;
row.push(Value::from(values[idx]));
}
matrix.push(Value::Array(row));
}
return Ok(Value::Array(matrix));
}
Err(replay_error(
ReplayErrorKind::ImportRejected,
format!("unsupported scene ref rank {}", shape.len()),
))
}
#[cfg(feature = "plot-core")]
struct SceneDataRef {
dataset_path: String,
array: String,
shape: Vec<usize>,
dtype: Option<String>,
chunks: Vec<SceneDataChunkRef>,
}
#[derive(Clone, Debug)]
struct SceneDataChunkRef {
src: Option<String>,
artifact_id: Option<String>,
}
#[cfg(feature = "plot-core")]
fn parse_data_ref(value: &Value) -> Option<SceneDataRef> {
let obj = value.as_object()?;
let ref_kind = obj.get("refKind")?.as_str()?;
if ref_kind != "runmat-data-array-v1" {
return None;
}
let dataset_path = obj.get("datasetPath")?.as_str()?.to_string();
let array = obj.get("array")?.as_str()?.to_string();
let shape = obj
.get("shape")
.and_then(Value::as_array)
.map(|dims| {
dims.iter()
.filter_map(Value::as_u64)
.map(|dim| dim as usize)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let dtype = obj
.get("dtype")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let chunks = obj
.get("chunks")
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(|entry| {
let obj = entry.as_object()?;
let src = obj
.get("src")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let artifact_id = obj
.get("artifactId")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
if src.is_none() && artifact_id.is_none() {
return None;
}
Some(SceneDataChunkRef { src, artifact_id })
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
Some(SceneDataRef {
dataset_path,
array,
shape,
dtype,
chunks,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::time::Instant;
#[cfg(feature = "plot-core")]
fn write_scene_dataset(
root_path: &str,
arrays: &[(&str, Vec<f64>)],
) -> Result<(), RuntimeError> {
let root = crate::data::dataset_root(root_path);
let mut manifest = crate::data::DataManifest {
schema_version: 1,
format: "runmat-data-scene-v1".to_string(),
dataset_id: "scene_ds".to_string(),
name: None,
created_at: crate::data::now_rfc3339(),
updated_at: crate::data::now_rfc3339(),
arrays: BTreeMap::new(),
attrs: BTreeMap::new(),
txn_sequence: 0,
};
for (array_name, values) in arrays {
let payload = crate::data::DataArrayPayload {
dtype: "f64".to_string(),
shape: vec![values.len()],
values: values.clone(),
};
let chunk = vec![std::cmp::max(1usize, values.len())];
let (payload_path, chunk_index_path) = futures::executor::block_on(
crate::data::write_array_payload_async(&root, array_name, &payload, &chunk),
)?;
let data_path = payload_path
.strip_prefix(&root)
.map_err(|err| replay_error(ReplayErrorKind::ImportRejected, err.to_string()))?
.to_string_lossy()
.to_string();
let chunk_index_rel = chunk_index_path
.strip_prefix(&root)
.map_err(|err| replay_error(ReplayErrorKind::ImportRejected, err.to_string()))?
.to_string_lossy()
.to_string();
manifest.arrays.insert(
(*array_name).to_string(),
crate::data::DataArrayMeta {
dtype: "f64".to_string(),
shape: vec![values.len()],
chunk_shape: chunk,
order: "column_major".to_string(),
codec: "none".to_string(),
chunk_index_path: Some(chunk_index_rel),
data_path,
},
);
}
futures::executor::block_on(crate::data::write_manifest_async(&root, &manifest))?;
Ok(())
}
#[cfg(feature = "plot-core")]
fn make_surface_ref_payload(dataset_path: &str) -> Vec<u8> {
let surface = runmat_plot::plots::SurfacePlot::new(
vec![0.0, 1.0],
vec![0.0, 1.0],
vec![vec![0.0, 1.0], vec![1.0, 2.0]],
)
.expect("surface");
let mut figure = runmat_plot::plots::Figure::new();
figure.add_surface_plot(surface);
let scene = runmat_plot::event::FigureScene::capture(&figure);
let mut payload = serde_json::to_value(FigureScenePayload {
schema_version: SCENE_SCHEMA_VERSION,
kind: SCENE_KIND.to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
figure: scene,
})
.expect("serialize payload");
let plot = payload["figure"]["plots"]
.as_array_mut()
.and_then(|plots| plots.get_mut(0))
.expect("first plot");
plot["x"] = serde_json::json!({
"refKind": "runmat-data-array-v1",
"datasetPath": dataset_path,
"array": "x",
"shape": [2]
});
plot["y"] = serde_json::json!({
"refKind": "runmat-data-array-v1",
"datasetPath": dataset_path,
"array": "y",
"shape": [2]
});
plot["z"] = serde_json::json!({
"refKind": "runmat-data-array-v1",
"datasetPath": dataset_path,
"array": "z",
"shape": [2, 2]
});
serde_json::to_vec(&payload).expect("payload bytes")
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_schema_mismatch_rejects() {
let scene = runmat_plot::event::FigureScene::capture(&runmat_plot::plots::Figure::new());
let mut payload = serde_json::to_value(FigureScenePayload {
schema_version: SCENE_SCHEMA_VERSION,
kind: SCENE_KIND.to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
figure: scene,
})
.expect("serialize payload");
payload["schemaVersion"] = serde_json::json!(99u32);
let bytes = serde_json::to_vec(&payload).expect("serialize bytes");
let err = decode_figure_scene_payload_with_limits(&bytes, ReplayLimits::default())
.expect_err("expected schema rejection");
assert_eq!(
err.identifier(),
Some(ReplayErrorKind::UnsupportedSchema.identifier())
);
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_payload_too_large_rejects() {
let scene = runmat_plot::event::FigureScene::capture(&runmat_plot::plots::Figure::new());
let bytes = encode_figure_scene_payload_with_limits(
&scene,
ReplayLimits {
max_scene_payload_bytes: 1024,
..ReplayLimits::default()
},
)
.expect("encode scene");
let err = decode_figure_scene_payload_with_limits(
&bytes,
ReplayLimits {
max_scene_payload_bytes: 1,
..ReplayLimits::default()
},
)
.expect_err("expected payload rejection");
assert_eq!(
err.identifier(),
Some(ReplayErrorKind::PayloadTooLarge.identifier())
);
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_data_refs_hydrate_via_runtime_data_store() {
let root = std::env::temp_dir().join(format!(
"runmat_scene_ref_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("unix epoch")
.as_nanos()
));
futures::executor::block_on(runmat_filesystem::create_dir_all_async(&root))
.expect("create temp root");
let dataset_path = root.join("surface_values.data");
let dataset_str = dataset_path.to_string_lossy().to_string();
write_scene_dataset(
&dataset_str,
&[
("x", vec![0.0, 1.0]),
("y", vec![0.0, 1.0]),
("z", vec![0.0, 1.0, 1.0, 2.0]),
],
)
.expect("write scene dataset");
let bytes = make_surface_ref_payload(&dataset_str);
let hydrated = decode_figure_scene_payload_with_limits(&bytes, ReplayLimits::default())
.expect("decode with hydrated refs");
match hydrated.plots.first() {
Some(runmat_plot::event::ScenePlot::Surface { x, y, z, .. }) => {
assert_eq!(x, &vec![0.0, 1.0]);
assert_eq!(y, &vec![0.0, 1.0]);
assert_eq!(z, &vec![vec![0.0, 1.0], vec![1.0, 2.0]]);
}
other => panic!("unexpected hydrated plot: {other:?}"),
}
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_data_refs_missing_chunk_rejects_import() {
let root = std::env::temp_dir().join(format!(
"runmat_scene_ref_missing_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("unix epoch")
.as_nanos()
));
futures::executor::block_on(runmat_filesystem::create_dir_all_async(&root))
.expect("create temp root");
let dataset_path = root.join("surface_values.data");
let dataset_str = dataset_path.to_string_lossy().to_string();
write_scene_dataset(
&dataset_str,
&[
("x", vec![0.0, 1.0]),
("y", vec![0.0, 1.0]),
("z", vec![0.0, 1.0, 1.0, 2.0]),
],
)
.expect("write scene dataset");
let missing_chunk = dataset_path.join("arrays/z/chunks/obj_0.json");
futures::executor::block_on(runmat_filesystem::remove_file_async(&missing_chunk))
.expect("remove z chunk");
let bytes = make_surface_ref_payload(&dataset_str);
let err = decode_figure_scene_payload_with_limits(&bytes, ReplayLimits::default())
.expect_err("expected import rejection");
assert_eq!(
err.identifier(),
Some(ReplayErrorKind::ImportRejected.identifier())
);
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_data_refs_corrupt_chunk_rejects_import() {
let root = std::env::temp_dir().join(format!(
"runmat_scene_ref_corrupt_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("unix epoch")
.as_nanos()
));
futures::executor::block_on(runmat_filesystem::create_dir_all_async(&root))
.expect("create temp root");
let dataset_path = root.join("surface_values.data");
let dataset_str = dataset_path.to_string_lossy().to_string();
write_scene_dataset(
&dataset_str,
&[
("x", vec![0.0, 1.0]),
("y", vec![0.0, 1.0]),
("z", vec![0.0, 1.0, 1.0, 2.0]),
],
)
.expect("write scene dataset");
let corrupt_chunk = dataset_path.join("arrays/z/chunks/obj_0.json");
futures::executor::block_on(runmat_filesystem::write_async(&corrupt_chunk, b"not-json"))
.expect("corrupt z chunk");
let bytes = make_surface_ref_payload(&dataset_str);
let err = decode_figure_scene_payload_with_limits(&bytes, ReplayLimits::default())
.expect_err("expected import rejection");
assert_eq!(
err.identifier(),
Some(ReplayErrorKind::ImportRejected.identifier())
);
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_data_refs_chunk_fallback_without_manifest_succeeds() {
let root = std::env::temp_dir().join(format!(
"runmat_scene_ref_chunk_fallback_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("unix epoch")
.as_nanos()
));
futures::executor::block_on(runmat_filesystem::create_dir_all_async(&root))
.expect("create temp root");
let chunk_path = root.join("z_chunk.json");
let chunk_payload = serde_json::json!({
"dtype": "f64",
"shape": [4],
"values": [0.0, 1.0, 1.0, 2.0]
});
futures::executor::block_on(runmat_filesystem::write_async(
&chunk_path,
serde_json::to_vec(&chunk_payload).expect("chunk json"),
))
.expect("write chunk");
let mut payload = serde_json::from_slice::<serde_json::Value>(&make_surface_ref_payload(
".artifacts/datasets/missing",
))
.expect("payload json");
let plot = payload["figure"]["plots"]
.as_array_mut()
.and_then(|plots| plots.get_mut(0))
.expect("first plot");
plot["z"] = serde_json::json!({
"refKind": "runmat-data-array-v1",
"datasetPath": ".artifacts/datasets/missing",
"array": "z",
"shape": [2, 2],
"dtype": "f64",
"chunks": [
{ "src": chunk_path.to_string_lossy() }
]
});
plot["x"] = serde_json::json!([0.0, 1.0]);
plot["y"] = serde_json::json!([0.0, 1.0]);
let bytes = serde_json::to_vec(&payload).expect("payload bytes");
let hydrated = decode_figure_scene_payload_with_limits(&bytes, ReplayLimits::default())
.expect("decode with chunk fallback");
match hydrated.plots.first() {
Some(runmat_plot::event::ScenePlot::Surface { z, .. }) => {
assert_eq!(z, &vec![vec![0.0, 1.0], vec![1.0, 2.0]]);
}
other => panic!("unexpected hydrated plot: {other:?}"),
}
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_chunk_reader_resolves_provider_relative_paths() {
let root = std::env::temp_dir().join(format!(
"runmat_scene_relpath_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("unix epoch")
.as_nanos()
));
let chunk_rel = ".artifacts/objects/ab/test_chunk.f64.chunk.json";
let chunk_path = root.join(chunk_rel);
futures::executor::block_on(runmat_filesystem::create_dir_all_async(
chunk_path.parent().expect("chunk parent"),
))
.expect("create chunk dir");
futures::executor::block_on(runmat_filesystem::write_async(
&chunk_path,
b"{\"values\":[1,2,3]}",
))
.expect("write chunk");
let previous = runmat_filesystem::current_dir().expect("current dir");
runmat_filesystem::set_current_dir(&root).expect("set cwd");
let chunk = SceneDataChunkRef {
src: Some(".artifacts/objects/ab/test_chunk.f64.chunk.json".to_string()),
artifact_id: None,
};
let data_ref = SceneDataRef {
dataset_path: ".artifacts/datasets/unused".to_string(),
array: "values".to_string(),
shape: vec![3],
dtype: Some("f64".to_string()),
chunks: vec![chunk.clone()],
};
let bytes =
read_scene_chunk_bytes(&chunk, &data_ref).expect("read chunk via relative path");
runmat_filesystem::set_current_dir(previous).expect("restore cwd");
assert_eq!(bytes, b"{\"values\":[1,2,3]}");
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_chunk_paths_from_artifact_id_includes_expected_candidates() {
let hash_hex = "7af8faff9e5fe6ba87fec8e4ce6d79dca7f29bbee9f9809a36119346b411ee36";
let candidates = chunk_paths_from_artifact_id(
&format!("sha256:{hash_hex}"),
Some("f64"),
Some(".artifacts"),
);
assert!(candidates.iter().any(|path| {
path == ".artifacts/objects/7a/7af8faff9e5fe6ba87fec8e4ce6d79dca7f29bbee9f9809a36119346b411ee36.f64.chunk.json"
}));
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_chunk_paths_from_artifact_id_uses_dataset_artifact_root() {
let hash_hex = "7af8faff9e5fe6ba87fec8e4ce6d79dca7f29bbee9f9809a36119346b411ee36";
let candidates = chunk_paths_from_artifact_id(
&format!("sha256:{hash_hex}"),
Some("f64"),
Some("custom-artifacts"),
);
assert!(candidates
.iter()
.any(|path| path == "custom-artifacts/objects/7a/7af8faff9e5fe6ba87fec8e4ce6d79dca7f29bbee9f9809a36119346b411ee36.f64.chunk.json"));
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_chunk_candidates_include_dot_slash_artifact_prefix() {
let chunk = SceneDataChunkRef {
src: Some(
".artifacts/objects/6a/6ac838c06809d12de7c81db7dbf1f17a7fcf7cb21d30a7288f5ad71d3a5b520d.f64.chunk.json"
.to_string(),
),
artifact_id: None,
};
let data_ref = SceneDataRef {
dataset_path: ".artifacts/datasets/unused".to_string(),
array: "values".to_string(),
shape: vec![1],
dtype: Some("f64".to_string()),
chunks: vec![chunk.clone()],
};
let candidates = build_scene_chunk_candidates(&chunk, &data_ref).expect("chunk candidates");
assert!(candidates
.iter()
.any(|path| path == ".artifacts/objects/6a/6ac838c06809d12de7c81db7dbf1f17a7fcf7cb21d30a7288f5ad71d3a5b520d.f64.chunk.json"));
assert!(candidates
.iter()
.any(|path| path == "/.artifacts/objects/6a/6ac838c06809d12de7c81db7dbf1f17a7fcf7cb21d30a7288f5ad71d3a5b520d.f64.chunk.json"));
}
#[cfg(feature = "plot-core")]
#[test]
fn scene_switch_decode_distinguishes_runs() {
let root = std::env::temp_dir().join(format!(
"runmat_scene_switch_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("unix epoch")
.as_nanos()
));
futures::executor::block_on(runmat_filesystem::create_dir_all_async(&root))
.expect("create temp root");
let dataset_a = root.join("surface_a.data");
let dataset_b = root.join("surface_b.data");
let dataset_a_str = dataset_a.to_string_lossy().to_string();
let dataset_b_str = dataset_b.to_string_lossy().to_string();
write_scene_dataset(
&dataset_a_str,
&[
("x", vec![0.0, 1.0]),
("y", vec![0.0, 1.0]),
("z", vec![0.0, 1.0, 1.0, 2.0]),
],
)
.expect("write dataset a");
write_scene_dataset(
&dataset_b_str,
&[
("x", vec![0.0, 1.0]),
("y", vec![0.0, 1.0]),
("z", vec![2.0, 3.0, 3.0, 4.0]),
],
)
.expect("write dataset b");
let bytes_a = make_surface_ref_payload(&dataset_a_str);
let bytes_b = make_surface_ref_payload(&dataset_b_str);
let scene_a = decode_figure_scene_payload_with_limits(&bytes_a, ReplayLimits::default())
.expect("decode scene a");
let scene_b = decode_figure_scene_payload_with_limits(&bytes_b, ReplayLimits::default())
.expect("decode scene b");
let z_a = match scene_a.plots.first() {
Some(runmat_plot::event::ScenePlot::Surface { z, .. }) => z.clone(),
other => panic!("unexpected scene a plot: {other:?}"),
};
let z_b = match scene_b.plots.first() {
Some(runmat_plot::event::ScenePlot::Surface { z, .. }) => z.clone(),
other => panic!("unexpected scene b plot: {other:?}"),
};
assert_ne!(z_a, z_b);
}
#[cfg(feature = "plot-core")]
#[test]
#[ignore = "benchmark sanity check"]
fn bench_scene_ref_hydration_large_surface() {
let root = std::env::temp_dir().join(format!(
"runmat_scene_bench_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("unix epoch")
.as_nanos()
));
futures::executor::block_on(runmat_filesystem::create_dir_all_async(&root))
.expect("create temp root");
let dataset_path = root.join("surface_bench.data");
let dataset_str = dataset_path.to_string_lossy().to_string();
let n = 512usize;
let x = (0..n).map(|i| i as f64).collect::<Vec<_>>();
let y = (0..n).map(|i| i as f64).collect::<Vec<_>>();
let mut z = Vec::with_capacity(n * n);
for row in 0..n {
for col in 0..n {
z.push((row as f64 * 0.01).sin() + (col as f64 * 0.01).cos());
}
}
write_scene_dataset(&dataset_str, &[("x", x), ("y", y), ("z", z)])
.expect("write bench dataset");
let mut payload =
serde_json::from_slice::<serde_json::Value>(&make_surface_ref_payload(&dataset_str))
.expect("bench payload json");
let plot = payload["figure"]["plots"]
.as_array_mut()
.and_then(|plots| plots.get_mut(0))
.expect("bench plot");
plot["x"]["shape"] = serde_json::json!([n]);
plot["y"]["shape"] = serde_json::json!([n]);
plot["z"]["shape"] = serde_json::json!([n, n]);
let bytes = serde_json::to_vec(&payload).expect("bench payload bytes");
let start = Instant::now();
let _scene = decode_figure_scene_payload_with_limits(&bytes, ReplayLimits::default())
.expect("decode bench scene");
let elapsed = start.elapsed();
eprintln!(
"scene bench: {}x{} surface decode+hydrate took {} ms",
n,
n,
elapsed.as_millis()
);
}
}