From 50cc292ed2c2df53e4829ffc64582431dbb7ed7e Mon Sep 17 00:00:00 2001 From: LinZhihao-723 Date: Tue, 7 Jul 2026 20:57:10 -0400 Subject: [PATCH 01/19] Done implementation. --- Cargo.lock | 11 + Cargo.toml | 1 + tests/huntsman/e2e/Cargo.toml | 12 + tests/huntsman/e2e/src/lib.rs | 7 + tests/huntsman/e2e/src/test_driver.rs | 331 ++++++++++++++++++++++++++ tests/huntsman/e2e/src/types.rs | 31 +++ 6 files changed, 393 insertions(+) create mode 100644 tests/huntsman/e2e/Cargo.toml create mode 100644 tests/huntsman/e2e/src/lib.rs create mode 100644 tests/huntsman/e2e/src/test_driver.rs create mode 100644 tests/huntsman/e2e/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 326b2b189..d384e3c71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,6 +452,17 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "e2e" +version = "0.1.0" +dependencies = [ + "anyhow", + "spider-client", + "spider-core", + "tokio", + "tonic", +] + [[package]] name = "either" version = "1.16.0" diff --git a/Cargo.toml b/Cargo.toml index c624e3e7e..e6ac488ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "components/spider-utils", "examples/huntsman/complex/tasks", "examples/huntsman/complex/types", + "tests/huntsman/e2e", "tests/huntsman/em-runtime", "tests/huntsman/integration-test-tasks", "tests/huntsman/task-executor", diff --git a/tests/huntsman/e2e/Cargo.toml b/tests/huntsman/e2e/Cargo.toml new file mode 100644 index 000000000..28fc8f44e --- /dev/null +++ b/tests/huntsman/e2e/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "e2e" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +anyhow = "1.0.98" +spider-client = { path = "../../../components/spider-client" } +spider-core = { path = "../../../components/spider-core" } +tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "sync", "time"] } +tonic = "0.14.6" diff --git a/tests/huntsman/e2e/src/lib.rs b/tests/huntsman/e2e/src/lib.rs new file mode 100644 index 000000000..9fb2353d1 --- /dev/null +++ b/tests/huntsman/e2e/src/lib.rs @@ -0,0 +1,7 @@ +//! End-to-end integration-test harness for the huntsman suites. + +pub mod test_driver; +mod types; + +pub use test_driver::SpiderTestDriver; +pub use types::*; diff --git a/tests/huntsman/e2e/src/test_driver.rs b/tests/huntsman/e2e/src/test_driver.rs new file mode 100644 index 000000000..700cfa505 --- /dev/null +++ b/tests/huntsman/e2e/src/test_driver.rs @@ -0,0 +1,331 @@ +//! The shared end-to-end test driver that submits jobs to a live Spider deployment and drives +//! them to a terminal state. +//! +//! The driver is a lazily initialized process-wide singleton. It reads its Spider endpoint and +//! concurrency configuration from environment variables and serializes access to the underlying +//! client so that shared scenarios run concurrently up to a configured limit while exclusive +//! scenarios run in isolation. + +use std::{collections::HashMap, num::NonZeroUsize, time::Duration}; + +use anyhow::Context; +use spider_client::SpiderClient; +use spider_core::{ + job::JobState, + types::id::{JobId, ResourceGroupId}, +}; +use tokio::sync::{Mutex, OnceCell, RwLock, Semaphore}; +use tonic::transport::Endpoint; + +use crate::types::{JobSubmission, TerminationResult}; + +/// A process-wide harness for running end-to-end Spider job scenarios. +pub struct SpiderTestDriver { + client: RwLock, + concurrency_limiter: Semaphore, + resource_groups: Mutex>, +} + +impl SpiderTestDriver { + /// Runs a job scenario concurrently with other shared scenarios, up to the configured + /// concurrency limit. + /// + /// # Type Parameters + /// + /// * `OutcomeAssertionType` - The callback for asserting the terminal outcome of the job. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`anyhow::Error`] if the concurrency limiter has been closed. + /// * Forwards [`Self::instance`]'s return values on failure. + /// * Forwards [`Self::resolve_resource_group`]'s return values on failure. + /// * Forwards [`run_scenario`]'s return values on failure. + pub async fn run( + job_submission: JobSubmission, + timeout: Duration, + outcome_assertion: OutcomeAssertionType, + ) -> anyhow::Result<()> + where + OutcomeAssertionType: AsyncFnOnce(JobId, TerminationResult) -> anyhow::Result<()>, { + let driver = Self::instance().await?; + let client_guard = driver.client.read().await; + let _permit = driver + .concurrency_limiter + .acquire() + .await + .context("concurrency limiter closed")?; + let client = client_guard.clone(); + let resource_group_id = driver + .resolve_resource_group(&client, &job_submission.resource_group_id) + .await?; + let result = run_scenario( + client, + resource_group_id, + job_submission, + timeout, + async |_job_id: JobId| -> anyhow::Result<()> { Ok(()) }, + outcome_assertion, + ) + .await; + drop(client_guard); + result + } + + /// Runs a job scenario in exclusive isolation. + /// + /// # Type Parameters + /// + /// * `FailureInjectionType` - A background task for injecting a failure into the running job. + /// * `OutcomeAssertionType` - The callback for asserting the terminal outcome of the job. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`Self::instance`]'s return values on failure. + /// * Forwards [`Self::resolve_resource_group`]'s return values on failure. + /// * Forwards [`run_scenario`]'s return values on failure. + pub async fn run_exclusive( + job_submission: JobSubmission, + timeout: Duration, + failure_injection: FailureInjectionType, + outcome_assertion: OutcomeAssertionType, + ) -> anyhow::Result<()> + where + FailureInjectionType: AsyncFnOnce(JobId) -> anyhow::Result<()>, + OutcomeAssertionType: AsyncFnOnce(JobId, TerminationResult) -> anyhow::Result<()>, { + let driver = Self::instance().await?; + let client_guard = driver.client.write().await; + let client = client_guard.clone(); + let resource_group_id = driver + .resolve_resource_group(&client, &job_submission.resource_group_id) + .await?; + let result = run_scenario( + client, + resource_group_id, + job_submission, + timeout, + failure_injection, + outcome_assertion, + ) + .await; + drop(client_guard); + result + } + + /// Returns the process-wide driver instance, initializing it on first access. + /// + /// # Returns + /// + /// A reference to the process-wide driver instance on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`Self::init`]'s return values on failure. + async fn instance() -> anyhow::Result<&'static Self> { + static INSTANCE: OnceCell = OnceCell::const_new(); + INSTANCE.get_or_try_init(Self::init).await + } + + /// Initializes the driver from the environment, connecting to the configured Spider endpoint. + /// + /// # Returns + /// + /// A newly initialized driver on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`anyhow::Error`] if: + /// * The endpoint environment variable is unset. + /// * The endpoint value is not a valid Spider endpoint. + /// * Forwards [`read_concurrency`]'s return values on failure. + /// * Forwards [`SpiderClient::connect`]'s return values on failure. + async fn init() -> anyhow::Result { + const ENDPOINT_ENV_VAR: &str = "SPIDER_ENDPOINT"; + let endpoint_string = std::env::var(ENDPOINT_ENV_VAR) + .with_context(|| format!("{ENDPOINT_ENV_VAR} is not set"))?; + let endpoint = Endpoint::from_shared(endpoint_string).context("invalid spider endpoint")?; + let concurrency = read_concurrency()?; + let client = SpiderClient::connect(endpoint, concurrency).await?; + Ok(Self { + client: RwLock::new(client), + concurrency_limiter: Semaphore::new(concurrency.get()), + resource_groups: Mutex::new(HashMap::new()), + }) + } + + /// Resolves an external resource-group id to a Spider-assigned id, registering the resource + /// group on first use and caching the result. + /// + /// # Returns + /// + /// The Spider-assigned resource-group id on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * Forwards [`SpiderClient::add_resource_group`]'s return values on failure. + async fn resolve_resource_group( + &self, + client: &SpiderClient, + external_resource_group_id: &str, + ) -> anyhow::Result { + let mut resource_groups = self.resource_groups.lock().await; + if let Some(resource_group_id) = resource_groups.get(external_resource_group_id) { + return Ok(*resource_group_id); + } + let resource_group_id = client + .add_resource_group(external_resource_group_id.to_owned(), Vec::new()) + .await?; + resource_groups.insert(external_resource_group_id.to_owned(), resource_group_id); + drop(resource_groups); + Ok(resource_group_id) + } +} + +/// Runs a single job scenario: submits and starts the job, drives it to a terminal state while +/// concurrently running the failure injection, and forwards the outcome to the assertion. +/// +/// # Type Parameters +/// +/// * `FailureInjectionType` - A background task for injecting a failure into the running job. +/// * `OutcomeAssertionType` - The callback for asserting the terminal outcome of the job. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * [`anyhow::Error`] if the job does not reach a terminal state within `timeout`. +/// * Forwards [`submit_and_start_job`]'s return values on failure. +/// * Forwards [`poll_until_terminal`]'s return values on failure. +/// * Forwards `failure_injection`'s return values on failure. +/// * Forwards `outcome_assertion`'s return values on failure. +async fn run_scenario( + client: SpiderClient, + resource_group_id: ResourceGroupId, + job_submission: JobSubmission, + timeout: Duration, + failure_injection: FailureInjectionType, + outcome_assertion: OutcomeAssertionType, +) -> anyhow::Result<()> +where + FailureInjectionType: AsyncFnOnce(JobId) -> anyhow::Result<()>, + OutcomeAssertionType: AsyncFnOnce(JobId, TerminationResult) -> anyhow::Result<()>, { + let job_id = submit_and_start_job(&client, resource_group_id, job_submission).await?; + let result = match tokio::time::timeout(timeout, async { + let ((), termination) = tokio::try_join!( + failure_injection(job_id), + poll_until_terminal(&client, job_id), + )?; + anyhow::Result::::Ok(termination) + }) + .await + { + Ok(termination) => termination?, + Err(_elapsed) => anyhow::bail!("job did not reach a terminal state within {timeout:?}"), + }; + outcome_assertion(job_id, result).await +} + +/// Submits and starts the described job. +/// +/// # Returns +/// +/// The id of the submitted job on success. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * Forwards [`SpiderClient::submit_job`]'s return values on failure. +/// * Forwards [`SpiderClient::start_job`]'s return values on failure. +async fn submit_and_start_job( + client: &SpiderClient, + resource_group_id: ResourceGroupId, + job_submission: JobSubmission, +) -> anyhow::Result { + let job_id = client + .submit_job( + resource_group_id, + &job_submission.task_graph, + job_submission.inputs, + ) + .await?; + client.start_job(job_id).await?; + Ok(job_id) +} + +/// Polls the job's state until it reaches a terminal state. +/// +/// # Returns +/// +/// The terminal outcome of the job on success. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * Forwards [`SpiderClient::get_job_state`]'s return values on failure. +/// * Forwards [`SpiderClient::get_job_outputs`]'s return values on failure. +/// * Forwards [`SpiderClient::get_job_error`]'s return values on failure. +async fn poll_until_terminal( + client: &SpiderClient, + job_id: JobId, +) -> anyhow::Result { + const POLL_INTERVAL: Duration = Duration::from_millis(10); + + loop { + match client.get_job_state(job_id).await? { + JobState::Succeeded => { + let outputs = client.get_job_outputs(job_id).await?; + return Ok(TerminationResult::Success(outputs)); + } + JobState::Failed => { + let error_message = client.get_job_error(job_id).await?; + return Ok(TerminationResult::Failure(error_message)); + } + JobState::Cancelled => return Ok(TerminationResult::Cancelled), + _ => tokio::time::sleep(POLL_INTERVAL).await, + } + } +} + +/// Reads the configured shared-scenario concurrency from the environment, falling back to a default +/// value when unset. +/// +/// # Returns +/// +/// The configured concurrency on success. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * [`anyhow::Error`] if the concurrency value is invalid. +/// +/// # Panics +/// +/// Panics if the default value is zero. +fn read_concurrency() -> anyhow::Result { + const CONCURRENCY_ENV_VAR: &str = "SPIDER_CONCURRENCY"; + const DEFAULT_CONCURRENCY: usize = 8; + + match std::env::var(CONCURRENCY_ENV_VAR) { + Ok(value) => value.parse::().with_context(|| { + format!("{CONCURRENCY_ENV_VAR} must be a positive integer, got {value:?}") + }), + Err(std::env::VarError::NotPresent) => { + Ok(NonZeroUsize::new(DEFAULT_CONCURRENCY).expect("default concurrency is non-zero")) + } + Err(error) => { + Err(error).with_context(|| format!("{CONCURRENCY_ENV_VAR} is not valid unicode")) + } + } +} diff --git a/tests/huntsman/e2e/src/types.rs b/tests/huntsman/e2e/src/types.rs new file mode 100644 index 000000000..3cd2fa933 --- /dev/null +++ b/tests/huntsman/e2e/src/types.rs @@ -0,0 +1,31 @@ +//! Public types shared across the end-to-end test driver. + +use spider_core::{ + task::TaskGraph, + types::io::{TaskInput, TaskOutput}, +}; + +/// The terminal outcome of a job returned from the test driver. +pub enum TerminationResult { + /// The job succeeded, carrying the collected task outputs. + Success(Vec), + + /// The job failed, carrying the reported error message. + Failure(String), + + /// The job was cancelled before completion. + Cancelled, +} + +/// A description of a single job to submit through the test driver. +pub struct JobSubmission { + /// The external resource-group id the job is submitted under. The driver resolves it to a + /// Spider-assigned ID, registering it on first use. + pub resource_group_id: String, + + /// The task graph describing the job's computation. + pub task_graph: TaskGraph, + + /// The inputs supplied to the job's entry tasks. + pub inputs: Vec, +} From 26d645610c9f736af5ca85fb0a6d6a015f60100d Mon Sep 17 00:00:00 2001 From: LinZhihao-723 Date: Tue, 7 Jul 2026 21:18:49 -0400 Subject: [PATCH 02/19] toml lint. --- tests/huntsman/e2e/Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/huntsman/e2e/Cargo.toml b/tests/huntsman/e2e/Cargo.toml index 28fc8f44e..9551154ba 100644 --- a/tests/huntsman/e2e/Cargo.toml +++ b/tests/huntsman/e2e/Cargo.toml @@ -8,5 +8,8 @@ publish = false anyhow = "1.0.98" spider-client = { path = "../../../components/spider-client" } spider-core = { path = "../../../components/spider-core" } -tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "sync", "time"] } +tokio = { + version = "1.52.3", + features = ["macros", "rt-multi-thread", "sync", "time"] +} tonic = "0.14.6" From e1aad84854340c99bfc25c6b50c644a8bd641413 Mon Sep 17 00:00:00 2001 From: LinZhihao-723 Date: Wed, 8 Jul 2026 17:41:16 -0400 Subject: [PATCH 03/19] Linter fix. --- tests/huntsman/e2e/src/test_driver.rs | 19 ++++++++++++------- tests/huntsman/e2e/src/types.rs | 7 +++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/tests/huntsman/e2e/src/test_driver.rs b/tests/huntsman/e2e/src/test_driver.rs index 700cfa505..eb74e86da 100644 --- a/tests/huntsman/e2e/src/test_driver.rs +++ b/tests/huntsman/e2e/src/test_driver.rs @@ -6,18 +6,23 @@ //! client so that shared scenarios run concurrently up to a configured limit while exclusive //! scenarios run in isolation. -use std::{collections::HashMap, num::NonZeroUsize, time::Duration}; +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::time::Duration; use anyhow::Context; use spider_client::SpiderClient; -use spider_core::{ - job::JobState, - types::id::{JobId, ResourceGroupId}, -}; -use tokio::sync::{Mutex, OnceCell, RwLock, Semaphore}; +use spider_core::job::JobState; +use spider_core::types::id::JobId; +use spider_core::types::id::ResourceGroupId; +use tokio::sync::Mutex; +use tokio::sync::OnceCell; +use tokio::sync::RwLock; +use tokio::sync::Semaphore; use tonic::transport::Endpoint; -use crate::types::{JobSubmission, TerminationResult}; +use crate::types::JobSubmission; +use crate::types::TerminationResult; /// A process-wide harness for running end-to-end Spider job scenarios. pub struct SpiderTestDriver { diff --git a/tests/huntsman/e2e/src/types.rs b/tests/huntsman/e2e/src/types.rs index 3cd2fa933..d687c9a2b 100644 --- a/tests/huntsman/e2e/src/types.rs +++ b/tests/huntsman/e2e/src/types.rs @@ -1,9 +1,8 @@ //! Public types shared across the end-to-end test driver. -use spider_core::{ - task::TaskGraph, - types::io::{TaskInput, TaskOutput}, -}; +use spider_core::task::TaskGraph; +use spider_core::types::io::TaskInput; +use spider_core::types::io::TaskOutput; /// The terminal outcome of a job returned from the test driver. pub enum TerminationResult { From 35670139849c03607b081c0b63b1606b250d2d4b Mon Sep 17 00:00:00 2001 From: LinZhihao-723 Date: Thu, 9 Jul 2026 21:56:54 -0400 Subject: [PATCH 04/19] Update comment. --- tests/huntsman/e2e/src/test_driver.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/huntsman/e2e/src/test_driver.rs b/tests/huntsman/e2e/src/test_driver.rs index eb74e86da..ce8e87d8d 100644 --- a/tests/huntsman/e2e/src/test_driver.rs +++ b/tests/huntsman/e2e/src/test_driver.rs @@ -317,7 +317,8 @@ async fn poll_until_terminal( /// /// # Panics /// -/// Panics if the default value is zero. +/// Panics if the default value is zero. This shouldn't be reachable at runtime: the default number +/// is a non-zero compile-time constant. fn read_concurrency() -> anyhow::Result { const CONCURRENCY_ENV_VAR: &str = "SPIDER_CONCURRENCY"; const DEFAULT_CONCURRENCY: usize = 8; From 4eaf570e32a51ba44ac4bda51cc7b72404f27872 Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Thu, 9 Jul 2026 23:59:43 -0400 Subject: [PATCH 05/19] Add nn network --- Cargo.lock | 4 + tests/huntsman/e2e/Cargo.toml | 4 + tests/huntsman/e2e/src/lib.rs | 3 + tests/huntsman/e2e/src/nn/mod.rs | 11 ++ tests/huntsman/e2e/src/nn/network.rs | 175 ++++++++++++++++++++++++ tests/huntsman/e2e/src/nn/neuron.rs | 46 +++++++ tests/huntsman/e2e/src/nn/wiring.rs | 146 ++++++++++++++++++++ tests/huntsman/e2e/src/payload_serde.rs | 154 +++++++++++++++++++++ 8 files changed, 543 insertions(+) create mode 100644 tests/huntsman/e2e/src/nn/mod.rs create mode 100644 tests/huntsman/e2e/src/nn/network.rs create mode 100644 tests/huntsman/e2e/src/nn/neuron.rs create mode 100644 tests/huntsman/e2e/src/nn/wiring.rs create mode 100644 tests/huntsman/e2e/src/payload_serde.rs diff --git a/Cargo.lock b/Cargo.lock index 43fd39429..57e454bfc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -457,6 +457,10 @@ name = "e2e" version = "0.1.0" dependencies = [ "anyhow", + "huntsman-nn-core", + "rand 0.9.4", + "rmp-serde", + "serde", "spider-client", "spider-core", "tokio", diff --git a/tests/huntsman/e2e/Cargo.toml b/tests/huntsman/e2e/Cargo.toml index 9551154ba..bab62c987 100644 --- a/tests/huntsman/e2e/Cargo.toml +++ b/tests/huntsman/e2e/Cargo.toml @@ -6,6 +6,10 @@ publish = false [dependencies] anyhow = "1.0.98" +huntsman-nn-core = { path = "../../../examples/huntsman/nn/core" } +rand = "0.9.1" +rmp-serde = "1.3.1" +serde = { version = "1.0.228", features = ["derive"] } spider-client = { path = "../../../components/spider-client" } spider-core = { path = "../../../components/spider-core" } tokio = { diff --git a/tests/huntsman/e2e/src/lib.rs b/tests/huntsman/e2e/src/lib.rs index 9fb2353d1..03e35b335 100644 --- a/tests/huntsman/e2e/src/lib.rs +++ b/tests/huntsman/e2e/src/lib.rs @@ -1,7 +1,10 @@ //! End-to-end integration-test harness for the huntsman suites. +pub mod nn; +pub mod payload_serde; pub mod test_driver; mod types; +pub use payload_serde::*; pub use test_driver::SpiderTestDriver; pub use types::*; diff --git a/tests/huntsman/e2e/src/nn/mod.rs b/tests/huntsman/e2e/src/nn/mod.rs new file mode 100644 index 000000000..601453400 --- /dev/null +++ b/tests/huntsman/e2e/src/nn/mod.rs @@ -0,0 +1,11 @@ +//! Self-contained neural-network model for the end-to-end test. +//! +//! [`NeuralNetwork`] builds a layered `neuron::dense_*` task graph and reproduces it in-process via +//! [`NeuralNetwork::simulate`], so a test can assert a live Spider run matches the simulation. + +mod network; +mod neuron; +mod wiring; + +pub use network::NeuralNetwork; +pub use neuron::Neuron; diff --git a/tests/huntsman/e2e/src/nn/network.rs b/tests/huntsman/e2e/src/nn/network.rs new file mode 100644 index 000000000..fcea1bd18 --- /dev/null +++ b/tests/huntsman/e2e/src/nn/network.rs @@ -0,0 +1,175 @@ +//! The neural-network model: a layered topology of `neuron::dense_*` neurons whose Spider +//! [`TaskGraph`] and in-process simulation describe the same DAG. + +use huntsman_nn_core::NUM_INPUTS; +use rand::SeedableRng; +use rand::rngs::StdRng; +use spider_core::task::DataTypeDescriptor; +use spider_core::task::TaskDescriptor; +use spider_core::task::TaskGraph; +use spider_core::task::TaskIndex; +use spider_core::task::TaskInputOutputIndex; +use spider_core::task::TdlContext; +use spider_core::task::ValueTypeDescriptor; + +use crate::nn::Neuron; +use crate::nn::wiring; + +/// Name of the TDL package supplying the `neuron::dense_*` tasks. +const PACKAGE: &str = "nn"; + +/// One layer of the network: its neuron count, activation, and per-neuron fan-in. +struct Layer { + /// Number of neurons in this layer. + neuron_count: usize, + /// Activation applied by every neuron in this layer. + activation: Neuron, + /// Per-neuron fan-in, listing previous-layer output indices feeding each neuron. + /// Empty for layer 0, which reads graph inputs directly. + fan_in: Vec>, +} + +/// A randomly-wired, layered neural network of `neuron::dense_*` neurons. +pub struct NeuralNetwork { + /// The layers in layer order. + layers: Vec, +} + +impl NeuralNetwork { + /// Factory function. + /// + /// Validates `layer_specs` via [`wiring::validate`] and generates the inner-layer fan-in + /// wiring deterministically from `seed`. + /// + /// # Returns + /// + /// The newly created [`NeuralNetwork`] on success. + /// + /// # Errors + /// + /// Forwards [`wiring::validate`]'s return values on failure. + pub fn new(layer_specs: Vec<(usize, Neuron)>, seed: u64) -> anyhow::Result { + let sizes: Vec = layer_specs.iter().map(|(size, _)| *size).collect(); + wiring::validate(&sizes)?; + let mut rng = StdRng::seed_from_u64(seed); + let fan_ins = wiring::build_wiring(&sizes, &mut rng); + let layers = layer_specs + .into_iter() + .zip(fan_ins) + .map(|((neuron_count, activation), fan_in)| Layer { + neuron_count, + activation, + fan_in, + }) + .collect(); + Ok(Self { layers }) + } + + /// # Returns + /// + /// The number of graph inputs. + #[must_use] + pub fn num_graph_inputs(&self) -> usize { + self.layers[0].neuron_count * NUM_INPUTS + } + + /// Builds the Spider [`TaskGraph`] for this network. + /// + /// # Returns + /// + /// The [`TaskGraph`] for this network on success. + /// + /// # Errors + /// + /// Forwards [`TaskGraph::new`]'s return values on failure. + /// Forwards [`TaskGraph::insert_task`]'s return values on failure. + pub fn to_task_graph(&self) -> anyhow::Result { + let float64 = DataTypeDescriptor::Value(ValueTypeDescriptor::float64()); + let mut graph = TaskGraph::new(None, None)?; + let first = &self.layers[0]; + let mut prev_layer: Vec = Vec::with_capacity(first.neuron_count); + + for _ in 0..first.neuron_count { + let task_idx = graph.insert_task(TaskDescriptor { + tdl_context: TdlContext { + package: PACKAGE.to_owned(), + task_func: first.activation.task_name().to_owned(), + }, + execution_policy: None, + inputs: vec![float64.clone(); NUM_INPUTS], + outputs: vec![float64.clone()], + input_sources: None, + })?; + prev_layer.push(task_idx); + } + + for layer in self.layers.iter().skip(1) { + let mut curr_layer = Vec::with_capacity(layer.neuron_count); + for j in 0..layer.neuron_count { + let input_sources: Vec = layer.fan_in[j] + .iter() + .map(|&src| TaskInputOutputIndex { + task_idx: prev_layer[src], + position: 0, + }) + .collect(); + let task_idx = graph.insert_task(TaskDescriptor { + tdl_context: TdlContext { + package: PACKAGE.to_owned(), + task_func: layer.activation.task_name().to_owned(), + }, + execution_policy: None, + inputs: vec![float64.clone(); NUM_INPUTS], + outputs: vec![float64.clone()], + input_sources: Some(input_sources), + })?; + curr_layer.push(task_idx); + } + prev_layer = curr_layer; + } + + Ok(graph) + } + + /// Computes the network's outputs from graph inputs. + /// + /// # Returns + /// + /// The network's outputs on success. + /// + /// # Errors + /// + /// Returns an error if: + /// + /// * [`anyhow::Error`] if `inputs` length is not [`Self::num_graph_inputs`]. + pub fn simulate(&self, inputs: &[f64]) -> anyhow::Result> { + let expected = self.num_graph_inputs(); + anyhow::ensure!( + inputs.len() == expected, + "expected {expected} graph inputs, got {}", + inputs.len(), + ); + + let first = &self.layers[0]; + let mut layer_outputs: Vec = (0..first.neuron_count) + .map(|i| { + let start = i * NUM_INPUTS; + let mut neuron_inputs = [0.0_f64; NUM_INPUTS]; + neuron_inputs.copy_from_slice(&inputs[start..start + NUM_INPUTS]); + first.activation.evaluate_func()(&neuron_inputs) + }) + .collect(); + + for layer in self.layers.iter().skip(1) { + layer_outputs = (0..layer.neuron_count) + .map(|i| { + let neuron_inputs: [f64; NUM_INPUTS] = + std::array::from_fn(|j| layer_outputs[layer.fan_in[i][j]]); + layer.activation.evaluate_func()(&neuron_inputs) + }) + .collect(); + } + + Ok(layer_outputs) + } +} diff --git a/tests/huntsman/e2e/src/nn/neuron.rs b/tests/huntsman/e2e/src/nn/neuron.rs new file mode 100644 index 000000000..f6338a1ee --- /dev/null +++ b/tests/huntsman/e2e/src/nn/neuron.rs @@ -0,0 +1,46 @@ +//! Activation functions for the end-to-end neural-network test workload. +//! +//! Each [`Neuron`] pairs a Spider `neuron::dense_*` task with the in-process +//! `huntsman_nn_core::dense_*` evaluation function so the task graph and [`super::NeuralNetwork`]'s +//! simulation share one source of truth. + +use huntsman_nn_core::NUM_INPUTS; + +/// A dense-layer neuron activation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Neuron { + /// Rectified-linear activation. + Relu, + + /// Logistic-sigmoid activation. + Sigmoid, + + /// Identity (no-op) activation. + Identity, +} + +impl Neuron { + /// # Returns + /// + /// The `neuron::dense_*` task function name that evaluates this activation. + #[must_use] + pub const fn task_name(self) -> &'static str { + match self { + Self::Relu => "neuron::dense_relu", + Self::Sigmoid => "neuron::dense_sigmoid", + Self::Identity => "neuron::dense_identity", + } + } + + /// # Returns + /// + /// The `huntsman_nn_core::dense_*` function that evaluates this activation. + #[must_use] + pub fn evaluate_func(self) -> fn(&[f64; NUM_INPUTS]) -> f64 { + match self { + Self::Relu => huntsman_nn_core::dense_relu, + Self::Sigmoid => huntsman_nn_core::dense_sigmoid, + Self::Identity => huntsman_nn_core::dense_identity, + } + } +} diff --git a/tests/huntsman/e2e/src/nn/wiring.rs b/tests/huntsman/e2e/src/nn/wiring.rs new file mode 100644 index 000000000..c7aec0c44 --- /dev/null +++ b/tests/huntsman/e2e/src/nn/wiring.rs @@ -0,0 +1,146 @@ +//! Topology wiring for the end-to-end neural-network test workload. +//! +//! Layer 0 neurons read graph inputs directly; inner-layer neurons each draw a fixed fan-in +//! from the previous layer's outputs. + +use huntsman_nn_core::NUM_INPUTS; +use rand::rngs::StdRng; +use rand::seq::SliceRandom; + +/// Validates the layer sizes against the following invariants: +/// +/// * The layer list is non-empty. +/// * Each layer's size is at least the neuron fan-in [`NUM_INPUTS`]. +/// * Each consecutive pair of layers fully covers the previous layer's outputs (`next_size * +/// NUM_INPUTS >= prev_size`). +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * [`anyhow::Error`] if an invariant is violated. +pub fn validate(sizes: &[usize]) -> anyhow::Result<()> { + anyhow::ensure!(!sizes.is_empty(), "at least one layer is required"); + for (i, &size) in sizes.iter().enumerate() { + anyhow::ensure!( + size >= NUM_INPUTS, + "layer {i} size {size} is smaller than the neuron fan-in {NUM_INPUTS}", + ); + } + for (i, window) in sizes.windows(2).enumerate() { + let layer_index = i + 1; + let prev_size = window[0]; + let next_size = window[1]; + anyhow::ensure!( + next_size * NUM_INPUTS >= prev_size, + "layer {layer_index}'s {next_size} * fan-in {NUM_INPUTS} cannot cover previous size \ + {prev_size}", + ); + } + Ok(()) +} + +/// Builds the per-neuron fan-in wiring for every layer. +/// +/// # Returns +/// +/// The per-neuron fan-in wiring, indexed `[layer][neuron][fan_in]`. `wiring[0]` is empty since +/// layer 0 reads graph inputs directly. +/// +/// # Panics +/// +/// Panics if the layer invariants do not hold. +pub fn build_wiring(sizes: &[usize], rng: &mut StdRng) -> Vec>> { + let mut wiring = Vec::with_capacity(sizes.len()); + wiring.push(Vec::new()); + for window in sizes.windows(2) { + let prev_size = window[0]; + let next_size = window[1]; + wiring.push(generate_layer_wiring(rng, prev_size, next_size)); + } + wiring +} + +/// Generates the fan-in for each neuron of one inner layer. +/// +/// # Returns +/// +/// One fan-in vector per neuron in the layer, each containing previous-layer output indices. +/// +/// # Panics +/// +/// Panics if the layer invariants do not hold, i.e. `next_size * NUM_INPUTS < prev_size` or +/// `prev_size < NUM_INPUTS`. +fn generate_layer_wiring(rng: &mut StdRng, prev_size: usize, next_size: usize) -> Vec> { + assert!( + prev_size >= NUM_INPUTS && next_size * NUM_INPUTS >= prev_size, + "layer invariants do not hold", + ); + let mut slots: Vec> = vec![Vec::new(); next_size]; + let mut dealer = Dealer::new(rng, prev_size); + + for neuron_slots in &mut slots { + for _ in 0..NUM_INPUTS { + let output = loop { + let candidate = dealer.draw(); + if !neuron_slots.contains(&candidate) { + break candidate; + } + }; + neuron_slots.push(output); + } + } + + slots +} + +/// Deals numbers from a shuffled deck, reshuffling a fresh permutation whenever the current deck +/// runs out. +struct Dealer<'a> { + rng: &'a mut StdRng, + range_size: usize, + deck: Vec, +} + +impl<'a> Dealer<'a> { + /// Factory function. + /// + /// # Returns + /// + /// The created [`Dealer`] with a freshly shuffled deck of 0..`range_size`. + fn new(rng: &'a mut StdRng, range_size: usize) -> Self { + let deck = shuffled_range(rng, range_size); + Self { + rng, + range_size, + deck, + } + } + + /// Draws the next number, reshuffling when the current deck is exhausted. + /// + /// # Returns + /// + /// The drawn number. + /// + /// # Panics + /// + /// Panics if a reshuffled deck is empty. + fn draw(&mut self) -> usize { + if self.deck.is_empty() { + self.deck = shuffled_range(self.rng, self.range_size); + } + self.deck.pop().expect("reshuffled deck must be non-empty") + } +} + +/// Produces a random permutation of the numbers in 0..`range_size`. +/// +/// # Returns +/// +/// The random permutation of 0..`range_size`. +fn shuffled_range(rng: &mut StdRng, range_size: usize) -> Vec { + let mut deck: Vec = (0..range_size).collect(); + deck.shuffle(rng); + deck +} diff --git a/tests/huntsman/e2e/src/payload_serde.rs b/tests/huntsman/e2e/src/payload_serde.rs new file mode 100644 index 000000000..6879b9190 --- /dev/null +++ b/tests/huntsman/e2e/src/payload_serde.rs @@ -0,0 +1,154 @@ +//! Spider task input/output wire-format codec. +//! +//! Converts between a Rust value and the `MessagePack`-encoded +//! [`TaskInput::ValuePayload`] / [`TaskOutput`] payload Spider exchanges over a single job +//! input/output boundary, so end-to-end tests can feed typed inputs and read typed outputs. + +use serde::Serialize; +use serde::de::DeserializeOwned; +use spider_core::types::io::TaskInput; +use spider_core::types::io::TaskOutput; + +/// Encodes `value` as a `MessagePack` [`TaskInput::ValuePayload`]. +/// +/// # Type Parameters +/// +/// * `T` - A serializable input value type. +/// +/// # Returns +/// +/// The msgpack-encoded [`TaskInput::ValuePayload`] on success. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * Forwards [`rmp_serde::to_vec`]'s return values on failure. +pub fn encode_input(value: &T) -> anyhow::Result +where + T: Serialize, { + Ok(TaskInput::ValuePayload(rmp_serde::to_vec(value)?)) +} + +/// Decodes a `MessagePack` [`TaskOutput`] payload into `T`. +/// +/// # Type Parameters +/// +/// * `T` - The deserialized output value type. +/// +/// # Returns +/// +/// The decoded `T` on success. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * Forwards [`rmp_serde::from_slice`]'s return values on failure. +pub fn decode_output(output: &TaskOutput) -> anyhow::Result +where + T: DeserializeOwned, { + Ok(rmp_serde::from_slice(output)?) +} + +#[cfg(test)] +mod tests { + use serde::Deserialize; + use serde::Serialize; + use serde::de::DeserializeOwned; + use spider_core::types::io::TaskInput; + use spider_core::types::io::TaskOutput; + + use super::decode_output; + use super::encode_input; + + /// Round-trips `value` through [`encode_input`] then [`decode_output`]. + fn round_trip(value: &T) -> T + where + T: Serialize + DeserializeOwned, { + let encoded = encode_input(value).expect("encode_input should succeed"); + let TaskInput::ValuePayload(bytes) = encoded; + decode_output(&bytes).expect("decode_output should succeed") + } + + #[test] + fn encode_input_wraps_msgpack_bytes_in_value_payload() { + let value = 42.0_f64; + let encoded = encode_input(&value).expect("encode_input should succeed"); + let expected = rmp_serde::to_vec(&value).expect("rmp_serde::to_vec should succeed"); + assert_eq!(encoded, TaskInput::ValuePayload(expected)); + } + + #[test] + fn round_trip_preserves_floats() { + let values = [ + 0.0_f64, + -0.0, + 1.5, + -2.25, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NAN, + f64::MIN, + f64::MAX, + f64::MIN_POSITIVE, + ]; + for &value in &values { + let got = round_trip(&value); + assert_eq!( + got.to_bits(), + value.to_bits(), + "float {value:?} not preserved" + ); + } + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Sample { + flag: bool, + count: i64, + label: String, + } + + #[test] + fn round_trip_preserves_struct() { + let value = Sample { + flag: true, + count: -7, + label: "hello".to_owned(), + }; + assert_eq!(round_trip(&value), value); + } + + #[test] + fn round_trip_preserves_multiple_values_end_to_end() { + let values: Vec = vec![1.5, -2.25, 3.0, 0.0, f64::INFINITY]; + let inputs: Vec = values + .iter() + .map(encode_input) + .collect::>>() + .expect("encoding all values should succeed"); + let outputs: Vec = inputs + .into_iter() + .map(|TaskInput::ValuePayload(bytes)| bytes) + .collect(); + let decoded: Vec = outputs + .iter() + .map(decode_output) + .collect::>>() + .expect("decoding all values should succeed"); + assert_eq!( + decoded.iter().map(|v| v.to_bits()).collect::>(), + values.iter().map(|v| v.to_bits()).collect::>(), + ); + } + + #[test] + fn decode_output_errors_on_empty_payload() { + let result = decode_output::(&TaskOutput::new()); + assert!( + result.is_err(), + "decoding an empty payload should fail, not panic" + ); + } +} From b3312ee61ca6ec043c62703f8554e369d7994fab Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Fri, 10 Jul 2026 00:22:44 -0400 Subject: [PATCH 06/19] Reorder --- tests/huntsman/e2e/src/nn/network.rs | 28 ++++++------ tests/huntsman/e2e/src/nn/wiring.rs | 66 ++++++++++++++-------------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/tests/huntsman/e2e/src/nn/network.rs b/tests/huntsman/e2e/src/nn/network.rs index fcea1bd18..20c5b1b1c 100644 --- a/tests/huntsman/e2e/src/nn/network.rs +++ b/tests/huntsman/e2e/src/nn/network.rs @@ -15,20 +15,6 @@ use spider_core::task::ValueTypeDescriptor; use crate::nn::Neuron; use crate::nn::wiring; -/// Name of the TDL package supplying the `neuron::dense_*` tasks. -const PACKAGE: &str = "nn"; - -/// One layer of the network: its neuron count, activation, and per-neuron fan-in. -struct Layer { - /// Number of neurons in this layer. - neuron_count: usize, - /// Activation applied by every neuron in this layer. - activation: Neuron, - /// Per-neuron fan-in, listing previous-layer output indices feeding each neuron. - /// Empty for layer 0, which reads graph inputs directly. - fan_in: Vec>, -} - /// A randomly-wired, layered neural network of `neuron::dense_*` neurons. pub struct NeuralNetwork { /// The layers in layer order. @@ -173,3 +159,17 @@ impl NeuralNetwork { Ok(layer_outputs) } } + +/// Name of the TDL package supplying the `neuron::dense_*` tasks. +const PACKAGE: &str = "nn"; + +/// One layer of the network: its neuron count, activation, and per-neuron fan-in. +struct Layer { + /// Number of neurons in this layer. + neuron_count: usize, + /// Activation applied by every neuron in this layer. + activation: Neuron, + /// Per-neuron fan-in, listing previous-layer output indices feeding each neuron. + /// Empty for layer 0, which reads graph inputs directly. + fan_in: Vec>, +} diff --git a/tests/huntsman/e2e/src/nn/wiring.rs b/tests/huntsman/e2e/src/nn/wiring.rs index c7aec0c44..2c610c2e4 100644 --- a/tests/huntsman/e2e/src/nn/wiring.rs +++ b/tests/huntsman/e2e/src/nn/wiring.rs @@ -61,39 +61,6 @@ pub fn build_wiring(sizes: &[usize], rng: &mut StdRng) -> Vec>> { wiring } -/// Generates the fan-in for each neuron of one inner layer. -/// -/// # Returns -/// -/// One fan-in vector per neuron in the layer, each containing previous-layer output indices. -/// -/// # Panics -/// -/// Panics if the layer invariants do not hold, i.e. `next_size * NUM_INPUTS < prev_size` or -/// `prev_size < NUM_INPUTS`. -fn generate_layer_wiring(rng: &mut StdRng, prev_size: usize, next_size: usize) -> Vec> { - assert!( - prev_size >= NUM_INPUTS && next_size * NUM_INPUTS >= prev_size, - "layer invariants do not hold", - ); - let mut slots: Vec> = vec![Vec::new(); next_size]; - let mut dealer = Dealer::new(rng, prev_size); - - for neuron_slots in &mut slots { - for _ in 0..NUM_INPUTS { - let output = loop { - let candidate = dealer.draw(); - if !neuron_slots.contains(&candidate) { - break candidate; - } - }; - neuron_slots.push(output); - } - } - - slots -} - /// Deals numbers from a shuffled deck, reshuffling a fresh permutation whenever the current deck /// runs out. struct Dealer<'a> { @@ -134,6 +101,39 @@ impl<'a> Dealer<'a> { } } +/// Generates the fan-in for each neuron of one inner layer. +/// +/// # Returns +/// +/// One fan-in vector per neuron in the layer, each containing previous-layer output indices. +/// +/// # Panics +/// +/// Panics if the layer invariants do not hold, i.e. `next_size * NUM_INPUTS < prev_size` or +/// `prev_size < NUM_INPUTS`. +fn generate_layer_wiring(rng: &mut StdRng, prev_size: usize, next_size: usize) -> Vec> { + assert!( + prev_size >= NUM_INPUTS && next_size * NUM_INPUTS >= prev_size, + "layer invariants do not hold", + ); + let mut slots: Vec> = vec![Vec::new(); next_size]; + let mut dealer = Dealer::new(rng, prev_size); + + for neuron_slots in &mut slots { + for _ in 0..NUM_INPUTS { + let output = loop { + let candidate = dealer.draw(); + if !neuron_slots.contains(&candidate) { + break candidate; + } + }; + neuron_slots.push(output); + } + } + + slots +} + /// Produces a random permutation of the numbers in 0..`range_size`. /// /// # Returns From a8d5ceeeb885ed32edd4822e79c449b6865dea62 Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Fri, 10 Jul 2026 00:48:31 -0400 Subject: [PATCH 07/19] Add nn e2e test --- tests/huntsman/e2e/tests/nn.rs | 99 ++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/huntsman/e2e/tests/nn.rs diff --git a/tests/huntsman/e2e/tests/nn.rs b/tests/huntsman/e2e/tests/nn.rs new file mode 100644 index 000000000..3fe5f2dc8 --- /dev/null +++ b/tests/huntsman/e2e/tests/nn.rs @@ -0,0 +1,99 @@ +//! End-to-end test: a layered `neuron::dense_*` task graph run through Spider must match the +//! in-process simulation. + +use std::time::Duration; + +use anyhow::bail; +use e2e::JobSubmission; +use e2e::SpiderTestDriver; +use e2e::TerminationResult; +use e2e::decode_output; +use e2e::encode_input; +use e2e::nn::NeuralNetwork; +use e2e::nn::Neuron; +use rand::Rng; +use rand::SeedableRng; +use rand::rngs::StdRng; + +/// Relative-tolerance float comparison. +const REL_TOL: f64 = 1.0e-12; + +/// Number of layers in the test network. +const NUM_LAYERS: usize = 10; + +/// Neurons per layer in the test network. +const LAYER_SIZE: usize = 1000; + +#[tokio::test] +async fn test_nn() -> anyhow::Result<()> { + if std::env::var("SPIDER_ENDPOINT").is_err() { + bail!("SPIDER_ENDPOINT is not set"); + } + + let layer_specs = (0..NUM_LAYERS) + .map(|i| { + ( + LAYER_SIZE, + if i % 2 == 0 { + Neuron::Relu + } else { + Neuron::Sigmoid + }, + ) + }) + .collect::>(); + let nn = NeuralNetwork::new(layer_specs, 0)?; + let inputs = random_f64s(nn.num_graph_inputs(), 0); + let expected = nn.simulate(&inputs)?; + let task_graph = nn.to_task_graph()?; + let job = JobSubmission { + resource_group_id: "e2e-nn".to_owned(), + task_graph, + inputs: inputs + .iter() + .map(encode_input) + .collect::>>()?, + }; + + SpiderTestDriver::run( + job, + Duration::from_secs(300), + async move |_job_id, result| { + let outputs = match result { + TerminationResult::Success(outputs) => outputs, + TerminationResult::Failure(message) => bail!("job failed: {message}"), + TerminationResult::Cancelled => bail!("job cancelled"), + }; + let actual: Vec = outputs + .iter() + .map(decode_output) + .collect::>>()?; + anyhow::ensure!( + actual.len() == expected.len(), + "expected {} outputs, got {}", + expected.len(), + actual.len(), + ); + for (&got, &exp) in actual.iter().zip(expected.iter()) { + let diff = (got - exp).abs(); + let tol = REL_TOL * (1.0 + exp.abs()); + assert!( + got.is_finite() && exp.is_finite() && diff <= tol, + "output mismatch: got={got}, expected={exp}, diff={diff}, tol={tol}", + ); + } + Ok(()) + }, + ) + .await?; + + Ok(()) +} + +/// # Returns +/// +/// `count` number of deterministic random `f64` values seeded by `seed`. +fn random_f64s(count: usize, seed: u64) -> Vec { + let mut rng = StdRng::seed_from_u64(seed); + (0..count).map(|_| rng.random::()).collect() +} From d386d2550fbf3b9a6a4ec6fc7341856f126b3a4c Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Fri, 10 Jul 2026 01:20:09 -0400 Subject: [PATCH 08/19] Remove e2e from unit test task --- taskfiles/test.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/taskfiles/test.yaml b/taskfiles/test.yaml index a7b197498..9c3d26a75 100644 --- a/taskfiles/test.yaml +++ b/taskfiles/test.yaml @@ -243,7 +243,8 @@ tasks: "{{.G_TDL_PACKAGES_DIR}}/complex/libcomplex.so" cp "{{.G_RUST_RELEASE_DIR}}/libintegration_test_tasks.so" \ "{{.G_TDL_PACKAGES_DIR}}/integration_test_tasks/libintegration_test_tasks.so" - cargo nextest run --all --all-features --run-ignored all --release + cargo nextest run --all --all-features --run-ignored all --release \ + -E 'not (package(e2e) & kind(test))' - |- for f in ${SPIDER_TEST_INSTRUMENT_OUTPUT_DIR}/*; do if [ -f "$f" ]; then From 37db769d6d6dbe40206a367db55e7e7d17df2735 Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Fri, 10 Jul 2026 01:47:46 -0400 Subject: [PATCH 09/19] Fix style --- tests/huntsman/e2e/src/nn/mod.rs | 2 +- tests/huntsman/e2e/src/nn/network.rs | 10 +++++++--- tests/huntsman/e2e/src/nn/wiring.rs | 14 +++++++++++--- tests/huntsman/e2e/src/payload_serde.rs | 2 +- tests/huntsman/e2e/tests/nn.rs | 8 ++++---- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/tests/huntsman/e2e/src/nn/mod.rs b/tests/huntsman/e2e/src/nn/mod.rs index 601453400..3d45023b4 100644 --- a/tests/huntsman/e2e/src/nn/mod.rs +++ b/tests/huntsman/e2e/src/nn/mod.rs @@ -1,7 +1,7 @@ //! Self-contained neural-network model for the end-to-end test. //! //! [`NeuralNetwork`] builds a layered `neuron::dense_*` task graph and reproduces it in-process via -//! [`NeuralNetwork::simulate`], so a test can assert a live Spider run matches the simulation. +//! [`NeuralNetwork::simulate`]. mod network; mod neuron; diff --git a/tests/huntsman/e2e/src/nn/network.rs b/tests/huntsman/e2e/src/nn/network.rs index 20c5b1b1c..a40634c14 100644 --- a/tests/huntsman/e2e/src/nn/network.rs +++ b/tests/huntsman/e2e/src/nn/network.rs @@ -33,7 +33,9 @@ impl NeuralNetwork { /// /// # Errors /// - /// Forwards [`wiring::validate`]'s return values on failure. + /// Returns an error if: + /// + /// * Forwards [`wiring::validate`]'s return values on failure. pub fn new(layer_specs: Vec<(usize, Neuron)>, seed: u64) -> anyhow::Result { let sizes: Vec = layer_specs.iter().map(|(size, _)| *size).collect(); wiring::validate(&sizes)?; @@ -67,8 +69,10 @@ impl NeuralNetwork { /// /// # Errors /// - /// Forwards [`TaskGraph::new`]'s return values on failure. - /// Forwards [`TaskGraph::insert_task`]'s return values on failure. + /// Returns an error if: + /// + /// * Forwards [`TaskGraph::new`]'s return values on failure. + /// * Forwards [`TaskGraph::insert_task`]'s return values on failure. pub fn to_task_graph(&self) -> anyhow::Result { let float64 = DataTypeDescriptor::Value(ValueTypeDescriptor::float64()); let mut graph = TaskGraph::new(None, None)?; diff --git a/tests/huntsman/e2e/src/nn/wiring.rs b/tests/huntsman/e2e/src/nn/wiring.rs index 2c610c2e4..b3a1daf22 100644 --- a/tests/huntsman/e2e/src/nn/wiring.rs +++ b/tests/huntsman/e2e/src/nn/wiring.rs @@ -18,7 +18,7 @@ use rand::seq::SliceRandom; /// /// Returns an error if: /// -/// * [`anyhow::Error`] if an invariant is violated. +/// * [`anyhow::Error`] if an invariant is voilated. pub fn validate(sizes: &[usize]) -> anyhow::Result<()> { anyhow::ensure!(!sizes.is_empty(), "at least one layer is required"); for (i, &size) in sizes.iter().enumerate() { @@ -31,8 +31,13 @@ pub fn validate(sizes: &[usize]) -> anyhow::Result<()> { let layer_index = i + 1; let prev_size = window[0]; let next_size = window[1]; + let coverage = next_size.checked_mul(NUM_INPUTS).ok_or_else(|| { + anyhow::anyhow!( + "layer {layer_index}'s {next_size} * fan-in {NUM_INPUTS} overflows usize", + ) + })?; anyhow::ensure!( - next_size * NUM_INPUTS >= prev_size, + coverage >= prev_size, "layer {layer_index}'s {next_size} * fan-in {NUM_INPUTS} cannot cover previous size \ {prev_size}", ); @@ -113,7 +118,10 @@ impl<'a> Dealer<'a> { /// `prev_size < NUM_INPUTS`. fn generate_layer_wiring(rng: &mut StdRng, prev_size: usize, next_size: usize) -> Vec> { assert!( - prev_size >= NUM_INPUTS && next_size * NUM_INPUTS >= prev_size, + prev_size >= NUM_INPUTS + && next_size + .checked_mul(NUM_INPUTS) + .is_some_and(|coverage| coverage >= prev_size), "layer invariants do not hold", ); let mut slots: Vec> = vec![Vec::new(); next_size]; diff --git a/tests/huntsman/e2e/src/payload_serde.rs b/tests/huntsman/e2e/src/payload_serde.rs index 6879b9190..55cf66fd6 100644 --- a/tests/huntsman/e2e/src/payload_serde.rs +++ b/tests/huntsman/e2e/src/payload_serde.rs @@ -2,7 +2,7 @@ //! //! Converts between a Rust value and the `MessagePack`-encoded //! [`TaskInput::ValuePayload`] / [`TaskOutput`] payload Spider exchanges over a single job -//! input/output boundary, so end-to-end tests can feed typed inputs and read typed outputs. +//! input/output boundary. use serde::Serialize; use serde::de::DeserializeOwned; diff --git a/tests/huntsman/e2e/tests/nn.rs b/tests/huntsman/e2e/tests/nn.rs index 3fe5f2dc8..be3b8f9fb 100644 --- a/tests/huntsman/e2e/tests/nn.rs +++ b/tests/huntsman/e2e/tests/nn.rs @@ -34,10 +34,10 @@ async fn test_nn() -> anyhow::Result<()> { .map(|i| { ( LAYER_SIZE, - if i % 2 == 0 { - Neuron::Relu - } else { - Neuron::Sigmoid + match i % 3 { + 0 => Neuron::Relu, + 1 => Neuron::Sigmoid, + _ => Neuron::Identity, }, ) }) From 5f37b1416ab53c228d8e84a3be1c97acf5208b7b Mon Sep 17 00:00:00 2001 From: sitaowang1998 Date: Thu, 20 Aug 2026 18:14:46 -0400 Subject: [PATCH 10/19] Apply suggestions from code review Co-authored-by: Lin Zhihao <59785146+LinZhihao-723@users.noreply.github.com> --- tests/huntsman/e2e/src/nn/wiring.rs | 6 +----- tests/huntsman/e2e/src/payload_serde.rs | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/huntsman/e2e/src/nn/wiring.rs b/tests/huntsman/e2e/src/nn/wiring.rs index b3a1daf22..e83cb3311 100644 --- a/tests/huntsman/e2e/src/nn/wiring.rs +++ b/tests/huntsman/e2e/src/nn/wiring.rs @@ -18,7 +18,7 @@ use rand::seq::SliceRandom; /// /// Returns an error if: /// -/// * [`anyhow::Error`] if an invariant is voilated. +/// * [`anyhow::Error`] if an invariant is violated. pub fn validate(sizes: &[usize]) -> anyhow::Result<()> { anyhow::ensure!(!sizes.is_empty(), "at least one layer is required"); for (i, &size) in sizes.iter().enumerate() { @@ -51,10 +51,6 @@ pub fn validate(sizes: &[usize]) -> anyhow::Result<()> { /// /// The per-neuron fan-in wiring, indexed `[layer][neuron][fan_in]`. `wiring[0]` is empty since /// layer 0 reads graph inputs directly. -/// -/// # Panics -/// -/// Panics if the layer invariants do not hold. pub fn build_wiring(sizes: &[usize], rng: &mut StdRng) -> Vec>> { let mut wiring = Vec::with_capacity(sizes.len()); wiring.push(Vec::new()); diff --git a/tests/huntsman/e2e/src/payload_serde.rs b/tests/huntsman/e2e/src/payload_serde.rs index 55cf66fd6..a49ba1ba8 100644 --- a/tests/huntsman/e2e/src/payload_serde.rs +++ b/tests/huntsman/e2e/src/payload_serde.rs @@ -24,9 +24,7 @@ use spider_core::types::io::TaskOutput; /// Returns an error if: /// /// * Forwards [`rmp_serde::to_vec`]'s return values on failure. -pub fn encode_input(value: &T) -> anyhow::Result -where - T: Serialize, { +pub fn encode_input(value: &T) -> anyhow::Result { Ok(TaskInput::ValuePayload(rmp_serde::to_vec(value)?)) } From c0b4ead300f1d3b41b766db1bf2e39742d0c8095 Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Thu, 20 Aug 2026 19:14:45 -0400 Subject: [PATCH 11/19] Remove lifetime --- tests/huntsman/e2e/src/nn/wiring.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/huntsman/e2e/src/nn/wiring.rs b/tests/huntsman/e2e/src/nn/wiring.rs index e83cb3311..bb2521fe5 100644 --- a/tests/huntsman/e2e/src/nn/wiring.rs +++ b/tests/huntsman/e2e/src/nn/wiring.rs @@ -64,24 +64,21 @@ pub fn build_wiring(sizes: &[usize], rng: &mut StdRng) -> Vec>> { /// Deals numbers from a shuffled deck, reshuffling a fresh permutation whenever the current deck /// runs out. -struct Dealer<'a> { - rng: &'a mut StdRng, +struct Dealer { range_size: usize, deck: Vec, } -impl<'a> Dealer<'a> { +impl Dealer { /// Factory function. /// /// # Returns /// /// The created [`Dealer`] with a freshly shuffled deck of 0..`range_size`. - fn new(rng: &'a mut StdRng, range_size: usize) -> Self { - let deck = shuffled_range(rng, range_size); + const fn new(range_size: usize) -> Self { Self { - rng, range_size, - deck, + deck: Vec::new(), } } @@ -94,9 +91,9 @@ impl<'a> Dealer<'a> { /// # Panics /// /// Panics if a reshuffled deck is empty. - fn draw(&mut self) -> usize { + fn draw(&mut self, rng: &mut StdRng) -> usize { if self.deck.is_empty() { - self.deck = shuffled_range(self.rng, self.range_size); + self.deck = shuffled_range(rng, self.range_size); } self.deck.pop().expect("reshuffled deck must be non-empty") } @@ -121,12 +118,12 @@ fn generate_layer_wiring(rng: &mut StdRng, prev_size: usize, next_size: usize) - "layer invariants do not hold", ); let mut slots: Vec> = vec![Vec::new(); next_size]; - let mut dealer = Dealer::new(rng, prev_size); + let mut dealer = Dealer::new(prev_size); for neuron_slots in &mut slots { for _ in 0..NUM_INPUTS { let output = loop { - let candidate = dealer.draw(); + let candidate = dealer.draw(rng); if !neuron_slots.contains(&candidate) { break candidate; } From 2b1d1de49a98e14705de18cd4931a92ef3120575 Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Thu, 20 Aug 2026 19:16:18 -0400 Subject: [PATCH 12/19] Rename --- tests/huntsman/e2e/src/nn/network.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/huntsman/e2e/src/nn/network.rs b/tests/huntsman/e2e/src/nn/network.rs index a40634c14..c811d4cf2 100644 --- a/tests/huntsman/e2e/src/nn/network.rs +++ b/tests/huntsman/e2e/src/nn/network.rs @@ -76,14 +76,14 @@ impl NeuralNetwork { pub fn to_task_graph(&self) -> anyhow::Result { let float64 = DataTypeDescriptor::Value(ValueTypeDescriptor::float64()); let mut graph = TaskGraph::new(None, None)?; - let first = &self.layers[0]; - let mut prev_layer: Vec = Vec::with_capacity(first.neuron_count); + let first_layer = &self.layers[0]; + let mut prev_layer: Vec = Vec::with_capacity(first_layer.neuron_count); - for _ in 0..first.neuron_count { + for _ in 0..first_layer.neuron_count { let task_idx = graph.insert_task(TaskDescriptor { tdl_context: TdlContext { package: PACKAGE.to_owned(), - task_func: first.activation.task_name().to_owned(), + task_func: first_layer.activation.task_name().to_owned(), }, execution_policy: None, inputs: vec![float64.clone(); NUM_INPUTS], @@ -140,13 +140,13 @@ impl NeuralNetwork { inputs.len(), ); - let first = &self.layers[0]; - let mut layer_outputs: Vec = (0..first.neuron_count) + let first_layer = &self.layers[0]; + let mut layer_outputs: Vec = (0..first_layer.neuron_count) .map(|i| { let start = i * NUM_INPUTS; let mut neuron_inputs = [0.0_f64; NUM_INPUTS]; neuron_inputs.copy_from_slice(&inputs[start..start + NUM_INPUTS]); - first.activation.evaluate_func()(&neuron_inputs) + first_layer.activation.evaluate_func()(&neuron_inputs) }) .collect(); From 9b4a314b18a6ab2fa45a391ee43530389194b706 Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Thu, 20 Aug 2026 19:23:46 -0400 Subject: [PATCH 13/19] Move private type definition down --- tests/huntsman/e2e/src/payload_serde.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/huntsman/e2e/src/payload_serde.rs b/tests/huntsman/e2e/src/payload_serde.rs index a49ba1ba8..bb293ad78 100644 --- a/tests/huntsman/e2e/src/payload_serde.rs +++ b/tests/huntsman/e2e/src/payload_serde.rs @@ -101,13 +101,6 @@ mod tests { } } - #[derive(Debug, PartialEq, Serialize, Deserialize)] - struct Sample { - flag: bool, - count: i64, - label: String, - } - #[test] fn round_trip_preserves_struct() { let value = Sample { @@ -149,4 +142,11 @@ mod tests { "decoding an empty payload should fail, not panic" ); } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Sample { + flag: bool, + count: i64, + label: String, + } } From 31e3aae96de8f40d7ffd9cb6d5cdaaca3c6236a3 Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Thu, 20 Aug 2026 19:25:11 -0400 Subject: [PATCH 14/19] Remove env test --- tests/huntsman/e2e/tests/nn.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/huntsman/e2e/tests/nn.rs b/tests/huntsman/e2e/tests/nn.rs index be3b8f9fb..4adba1b59 100644 --- a/tests/huntsman/e2e/tests/nn.rs +++ b/tests/huntsman/e2e/tests/nn.rs @@ -26,9 +26,6 @@ const LAYER_SIZE: usize = 1000; #[tokio::test] async fn test_nn() -> anyhow::Result<()> { - if std::env::var("SPIDER_ENDPOINT").is_err() { - bail!("SPIDER_ENDPOINT is not set"); - } let layer_specs = (0..NUM_LAYERS) .map(|i| { From 57dabd273c92744d26eb944839fc4f698f12acdd Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Thu, 20 Aug 2026 19:51:22 -0400 Subject: [PATCH 15/19] Run multiple jobs --- tests/huntsman/e2e/tests/nn.rs | 102 +++++++++++++++++++++++---------- 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/tests/huntsman/e2e/tests/nn.rs b/tests/huntsman/e2e/tests/nn.rs index 4adba1b59..ffd0b062c 100644 --- a/tests/huntsman/e2e/tests/nn.rs +++ b/tests/huntsman/e2e/tests/nn.rs @@ -3,6 +3,7 @@ use std::time::Duration; +use anyhow::Context; use anyhow::bail; use e2e::JobSubmission; use e2e::SpiderTestDriver; @@ -14,6 +15,7 @@ use e2e::nn::Neuron; use rand::Rng; use rand::SeedableRng; use rand::rngs::StdRng; +use tokio::task::JoinSet; /// Relative-tolerance float comparison. const REL_TOL: f64 = 1.0e-12; @@ -24,9 +26,51 @@ const NUM_LAYERS: usize = 10; /// Neurons per layer in the test network. const LAYER_SIZE: usize = 1000; +/// Maximum duration of one neural-network job. +const JOB_TIMEOUT: Duration = Duration::from_secs(300); + +/// Number of neural-network job batches. +const NUM_BATCHES: usize = 3; + +/// Number of concurrent neural-network jobs in each batch. +const NUM_JOBS_PER_BATCH: usize = 8; + #[tokio::test] async fn test_nn() -> anyhow::Result<()> { + for batch_index in 0..NUM_BATCHES { + let mut jobs = JoinSet::new(); + for job_index in 0..NUM_JOBS_PER_BATCH { + let seed = u64::try_from(batch_index * NUM_JOBS_PER_BATCH + job_index) + .expect("neural-network job index does not fit in u64"); + jobs.spawn(async move { + run_neural_network_job(seed).await.with_context(|| { + format!( + "neural-network job {job_index} in batch {batch_index} with seed {seed} \ + failed" + ) + }) + }); + } + while let Some(result) = jobs.join_next().await { + result.context("neural-network job task panicked")??; + } + } + + Ok(()) +} +/// Runs a neural-network job and validates its outputs against the in-process simulation. +/// +/// # Errors +/// +/// Returns an error if: +/// +/// * Forwards [`NeuralNetwork::new`]'s return values on failure. +/// * Forwards [`NeuralNetwork::simulate`]'s return values on failure. +/// * Forwards [`NeuralNetwork::to_task_graph`]'s return values on failure. +/// * Forwards [`encode_input`]'s return values on failure. +/// * Forwards [`SpiderTestDriver::run`]'s return values on failure. +async fn run_neural_network_job(seed: u64) -> anyhow::Result<()> { let layer_specs = (0..NUM_LAYERS) .map(|i| { ( @@ -39,8 +83,8 @@ async fn test_nn() -> anyhow::Result<()> { ) }) .collect::>(); - let nn = NeuralNetwork::new(layer_specs, 0)?; - let inputs = random_f64s(nn.num_graph_inputs(), 0); + let nn = NeuralNetwork::new(layer_specs, seed)?; + let inputs = random_f64s(nn.num_graph_inputs(), seed); let expected = nn.simulate(&inputs)?; let task_graph = nn.to_task_graph()?; let job = JobSubmission { @@ -52,36 +96,32 @@ async fn test_nn() -> anyhow::Result<()> { .collect::>>()?, }; - SpiderTestDriver::run( - job, - Duration::from_secs(300), - async move |_job_id, result| { - let outputs = match result { - TerminationResult::Success(outputs) => outputs, - TerminationResult::Failure(message) => bail!("job failed: {message}"), - TerminationResult::Cancelled => bail!("job cancelled"), - }; - let actual: Vec = outputs - .iter() - .map(decode_output) - .collect::>>()?; - anyhow::ensure!( - actual.len() == expected.len(), - "expected {} outputs, got {}", - expected.len(), - actual.len(), + SpiderTestDriver::run(job, JOB_TIMEOUT, async move |_job_id, result| { + let outputs = match result { + TerminationResult::Success(outputs) => outputs, + TerminationResult::Failure(message) => bail!("job failed: {message}"), + TerminationResult::Cancelled => bail!("job cancelled"), + }; + let actual: Vec = outputs + .iter() + .map(decode_output) + .collect::>>()?; + anyhow::ensure!( + actual.len() == expected.len(), + "expected {} outputs, got {}", + expected.len(), + actual.len(), + ); + for (&got, &exp) in actual.iter().zip(expected.iter()) { + let diff = (got - exp).abs(); + let tol = REL_TOL * (1.0 + exp.abs()); + assert!( + got.is_finite() && exp.is_finite() && diff <= tol, + "output mismatch: got={got}, expected={exp}, diff={diff}, tol={tol}", ); - for (&got, &exp) in actual.iter().zip(expected.iter()) { - let diff = (got - exp).abs(); - let tol = REL_TOL * (1.0 + exp.abs()); - assert!( - got.is_finite() && exp.is_finite() && diff <= tol, - "output mismatch: got={got}, expected={exp}, diff={diff}, tol={tol}", - ); - } - Ok(()) - }, - ) + } + Ok(()) + }) .await?; Ok(()) From 0fa8c54fda0bfc71bc55ac05f73e4a5753323003 Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Thu, 20 Aug 2026 22:25:10 -0400 Subject: [PATCH 16/19] Move type definition --- tests/huntsman/e2e/src/payload_serde.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/huntsman/e2e/src/payload_serde.rs b/tests/huntsman/e2e/src/payload_serde.rs index bb293ad78..5132b7032 100644 --- a/tests/huntsman/e2e/src/payload_serde.rs +++ b/tests/huntsman/e2e/src/payload_serde.rs @@ -60,6 +60,13 @@ mod tests { use super::decode_output; use super::encode_input; + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Sample { + flag: bool, + count: i64, + label: String, + } + /// Round-trips `value` through [`encode_input`] then [`decode_output`]. fn round_trip(value: &T) -> T where @@ -142,11 +149,4 @@ mod tests { "decoding an empty payload should fail, not panic" ); } - - #[derive(Debug, PartialEq, Serialize, Deserialize)] - struct Sample { - flag: bool, - count: i64, - label: String, - } } From 56454947e634a1d2d8157e84ec71cb80bfe752f7 Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Fri, 21 Aug 2026 11:59:27 -0400 Subject: [PATCH 17/19] Fix where --- tests/huntsman/e2e/src/payload_serde.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/huntsman/e2e/src/payload_serde.rs b/tests/huntsman/e2e/src/payload_serde.rs index 5132b7032..e0e3243ce 100644 --- a/tests/huntsman/e2e/src/payload_serde.rs +++ b/tests/huntsman/e2e/src/payload_serde.rs @@ -43,9 +43,7 @@ pub fn encode_input(value: &T) -> anyhow::Result { /// Returns an error if: /// /// * Forwards [`rmp_serde::from_slice`]'s return values on failure. -pub fn decode_output(output: &TaskOutput) -> anyhow::Result -where - T: DeserializeOwned, { +pub fn decode_output(output: &TaskOutput) -> anyhow::Result { Ok(rmp_serde::from_slice(output)?) } @@ -68,9 +66,7 @@ mod tests { } /// Round-trips `value` through [`encode_input`] then [`decode_output`]. - fn round_trip(value: &T) -> T - where - T: Serialize + DeserializeOwned, { + fn round_trip(value: &T) -> T { let encoded = encode_input(value).expect("encode_input should succeed"); let TaskInput::ValuePayload(bytes) = encoded; decode_output(&bytes).expect("decode_output should succeed") From 374aa18dec61bd9a22aa24822a07c985a95250b3 Mon Sep 17 00:00:00 2001 From: LinZhihao-723 Date: Fri, 21 Aug 2026 18:53:35 -0400 Subject: [PATCH 18/19] build(huntsman): Declare the e2e `nn` integration test as an opt-in Cargo target so the Rust unit-test flow no longer needs a nextest filter expression. Mark the `nn` integration test target with `test = false` so it is excluded from default target selection, and drop the `-E 'not (package(e2e) & kind(test))'` filter from the unit-test task. The target is still built and driven by nextest through explicit selection: cargo nextest run -p e2e --all-features --release --test nn --- taskfiles/test.yaml | 3 +-- tests/huntsman/e2e/Cargo.toml | 4 ++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/taskfiles/test.yaml b/taskfiles/test.yaml index 99021b244..b239f8960 100644 --- a/taskfiles/test.yaml +++ b/taskfiles/test.yaml @@ -248,8 +248,7 @@ tasks: "{{.G_TDL_PACKAGES_DIR}}/complex/libcomplex.so" cp "{{.G_RUST_RELEASE_DIR}}/libintegration_test_tasks.so" \ "{{.G_TDL_PACKAGES_DIR}}/integration_test_tasks/libintegration_test_tasks.so" - cargo nextest run --all --all-features --run-ignored all --release \ - -E 'not (package(e2e) & kind(test))' + cargo nextest run --all --all-features --run-ignored all --release - |- for f in ${SPIDER_TEST_INSTRUMENT_OUTPUT_DIR}/*; do if [ -f "$f" ]; then diff --git a/tests/huntsman/e2e/Cargo.toml b/tests/huntsman/e2e/Cargo.toml index b7aa0846c..9a3475afc 100644 --- a/tests/huntsman/e2e/Cargo.toml +++ b/tests/huntsman/e2e/Cargo.toml @@ -4,6 +4,10 @@ version = { workspace = true } edition = { workspace = true } publish = false +[[test]] +name = "nn" +test = false + [dependencies] anyhow = { workspace = true } huntsman-nn-core = { path = "../../../examples/huntsman/nn/core" } From e57aefa3ef273e6d161f945f0d8eed7e1657511b Mon Sep 17 00:00:00 2001 From: Sitao Wang Date: Fri, 21 Aug 2026 20:17:19 -0400 Subject: [PATCH 19/19] Move consts --- tests/huntsman/e2e/tests/nn.rs | 36 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/huntsman/e2e/tests/nn.rs b/tests/huntsman/e2e/tests/nn.rs index ffd0b062c..12f0724cf 100644 --- a/tests/huntsman/e2e/tests/nn.rs +++ b/tests/huntsman/e2e/tests/nn.rs @@ -17,26 +17,14 @@ use rand::SeedableRng; use rand::rngs::StdRng; use tokio::task::JoinSet; -/// Relative-tolerance float comparison. -const REL_TOL: f64 = 1.0e-12; - -/// Number of layers in the test network. -const NUM_LAYERS: usize = 10; - -/// Neurons per layer in the test network. -const LAYER_SIZE: usize = 1000; - -/// Maximum duration of one neural-network job. -const JOB_TIMEOUT: Duration = Duration::from_secs(300); - -/// Number of neural-network job batches. -const NUM_BATCHES: usize = 3; - -/// Number of concurrent neural-network jobs in each batch. -const NUM_JOBS_PER_BATCH: usize = 8; - #[tokio::test] async fn test_nn() -> anyhow::Result<()> { + /// Number of neural-network job batches. + const NUM_BATCHES: usize = 3; + + /// Number of concurrent neural-network jobs in each batch. + const NUM_JOBS_PER_BATCH: usize = 8; + for batch_index in 0..NUM_BATCHES { let mut jobs = JoinSet::new(); for job_index in 0..NUM_JOBS_PER_BATCH { @@ -71,6 +59,18 @@ async fn test_nn() -> anyhow::Result<()> { /// * Forwards [`encode_input`]'s return values on failure. /// * Forwards [`SpiderTestDriver::run`]'s return values on failure. async fn run_neural_network_job(seed: u64) -> anyhow::Result<()> { + /// Relative-tolerance float comparison. + const REL_TOL: f64 = 1.0e-12; + + /// Number of layers in the test network. + const NUM_LAYERS: usize = 10; + + /// Neurons per layer in the test network. + const LAYER_SIZE: usize = 1000; + + /// Maximum duration of one neural-network job. + const JOB_TIMEOUT: Duration = Duration::from_secs(600); + let layer_specs = (0..NUM_LAYERS) .map(|i| { (