diff --git a/vcv/test/smoke_test.cpp b/vcv/test/smoke_test.cpp new file mode 100644 index 0000000..e5ca5ca --- /dev/null +++ b/vcv/test/smoke_test.cpp @@ -0,0 +1,252 @@ +/** + * Standalone smoke test for MEMLNaut VCV module logic. + * Tests the IML inference pipeline without the VCV Rack runtime. + * Simulates what process() does: input CVs → IML → output CVs. + */ +#include +#include +#include +#include +#include +#include +#include + +static constexpr int NUM_INPUTS = 2; +static constexpr int NUM_OUTPUTS = 12; + +struct SmokeTestResults { + int passed = 0; + int failed = 0; + void check(const char* name, bool ok) { + if (ok) { passed++; std::cout << " PASS: " << name << "\n"; } + else { failed++; std::cerr << " FAIL: " << name << "\n"; } + } +}; + +// Test 1: Outputs are valid after inference +bool test_basic_inference(SmokeTestResults& r) { + std::cout << "\n--- Test: Basic inference pipeline ---\n"; + nisps::IML iml(NUM_INPUTS, NUM_OUTPUTS, {16, 24, 16}); + + // Simulate input: X=0.5, Y=0.5 (center position) + iml.set_input(0, 0.5f); + iml.set_input(1, 0.5f); + iml.process(); + + const float* outs = iml.get_outputs(); + bool all_valid = true; + for (int i = 0; i < NUM_OUTPUTS; i++) { + if (std::isnan(outs[i]) || std::isinf(outs[i]) || outs[i] < 0.f || outs[i] > 1.f) { + std::cerr << " Output " << i << " = " << outs[i] << " (invalid!)\n"; + all_valid = false; + } + } + r.check("All 12 outputs in [0,1]", all_valid); + return all_valid; +} + +// Test 2: Outputs change when inputs change +bool test_outputs_respond_to_inputs(SmokeTestResults& r) { + std::cout << "\n--- Test: Outputs respond to input changes ---\n"; + nisps::IML iml(NUM_INPUTS, NUM_OUTPUTS, {16, 24, 16}); + + // Get outputs at (0.1, 0.1) + iml.set_input(0, 0.1f); + iml.set_input(1, 0.1f); + iml.process(); + const float* outs1 = iml.get_outputs(); + std::vector snapshot1(outs1, outs1 + NUM_OUTPUTS); + + // Get outputs at (0.9, 0.9) + iml.set_input(0, 0.9f); + iml.set_input(1, 0.9f); + iml.process(); + const float* outs2 = iml.get_outputs(); + + // At least some outputs should differ + int changed = 0; + for (int i = 0; i < NUM_OUTPUTS; i++) { + if (std::abs(outs2[i] - snapshot1[i]) > 0.001f) changed++; + } + std::cout << " " << changed << "/" << NUM_OUTPUTS << " outputs changed\n"; + r.check("At least 6 outputs changed between (0.1,0.1) and (0.9,0.9)", changed >= 6); + return changed >= 6; +} + +// Test 3: Randomize produces different mappings +bool test_randomize_changes_outputs(SmokeTestResults& r) { + std::cout << "\n--- Test: Randomize produces different mappings ---\n"; + nisps::IML iml(NUM_INPUTS, NUM_OUTPUTS, {16, 24, 16}); + + iml.set_input(0, 0.5f); + iml.set_input(1, 0.5f); + iml.process(); + const float* outs1 = iml.get_outputs(); + std::vector before(outs1, outs1 + NUM_OUTPUTS); + + // Randomize with spread=0.6 — this internally re-runs inference + iml.set_mode(nisps::IML::Mode::Training); + iml.randomise_weights(0.6f); + iml.set_mode(nisps::IML::Mode::Inference); + + // get_outputs() now reflects the new weights (randomise_weights runs inference internally) + const float* outs2 = iml.get_outputs(); + + int changed = 0; + for (int i = 0; i < NUM_OUTPUTS; i++) { + if (std::abs(outs2[i] - before[i]) > 0.001f) changed++; + } + std::cout << " " << changed << "/" << NUM_OUTPUTS << " outputs changed after randomize\n"; + r.check("Randomize changes most outputs", changed >= 8); + return changed >= 8; +} + +// Test 4: Spread=0 vs spread=1 produce different weight distributions +bool test_spread_affects_distribution(SmokeTestResults& r) { + std::cout << "\n--- Test: Spread parameter affects output distribution ---\n"; + + auto get_output_stats = [](float spread, int trials) { + float total_extreme = 0; + int total_samples = 0; + for (int t = 0; t < trials; t++) { + nisps::IML iml(NUM_INPUTS, NUM_OUTPUTS, {16, 24, 16}); + iml.set_mode(nisps::IML::Mode::Training); + iml.randomise_weights(spread); + iml.set_mode(nisps::IML::Mode::Inference); + + for (float x = 0.1f; x <= 0.9f; x += 0.2f) { + for (float y = 0.1f; y <= 0.9f; y += 0.2f) { + iml.set_input(0, x); + iml.set_input(1, y); + iml.process(); + const float* outs = iml.get_outputs(); + for (int i = 0; i < NUM_OUTPUTS; i++) { + if (outs[i] < 0.1f || outs[i] > 0.9f) total_extreme++; + total_samples++; + } + } + } + } + return total_extreme / total_samples; + }; + + float extreme_ratio_spread0 = get_output_stats(0.0f, 10); + float extreme_ratio_spread1 = get_output_stats(1.0f, 10); + + std::cout << " Spread=0 extreme ratio: " << extreme_ratio_spread0 << "\n"; + std::cout << " Spread=1 extreme ratio: " << extreme_ratio_spread1 << "\n"; + + // Spread parameter should produce measurably different distributions. + // Note: with small networks [16,24,16], spread=0 doesn't necessarily produce + // MORE extreme outputs (fan-in too small to saturate sigmoid). The saturation + // effect is architecture-dependent. Just verify the distributions differ. + float diff = std::abs(extreme_ratio_spread0 - extreme_ratio_spread1); + std::cout << " Distribution difference: " << diff << "\n"; + r.check("Spread=0 and spread=1 produce different distributions (diff > 0.01)", diff > 0.01f); + return diff > 0.01f; +} + +// Test 5: Network expressiveness — different input regions produce different output patterns +bool test_network_expressiveness(SmokeTestResults& r) { + std::cout << "\n--- Test: Network [16,24,16] is expressive for 12 outputs ---\n"; + nisps::IML iml(NUM_INPUTS, NUM_OUTPUTS, {16, 24, 16}); + iml.set_mode(nisps::IML::Mode::Training); + iml.randomise_weights(0.6f); + iml.set_mode(nisps::IML::Mode::Inference); + + // Sample 4 corners of input space + float corners[4][2] = {{0.1f, 0.1f}, {0.9f, 0.1f}, {0.1f, 0.9f}, {0.9f, 0.9f}}; + std::vector> corner_outputs(4); + + for (int c = 0; c < 4; c++) { + iml.set_input(0, corners[c][0]); + iml.set_input(1, corners[c][1]); + iml.process(); + const float* outs = iml.get_outputs(); + corner_outputs[c].assign(outs, outs + NUM_OUTPUTS); + } + + // Check pairwise distances — all corner pairs should produce distinct output vectors + int distinct_pairs = 0; + for (int a = 0; a < 4; a++) { + for (int b = a + 1; b < 4; b++) { + float dist = 0; + for (int i = 0; i < NUM_OUTPUTS; i++) { + float d = corner_outputs[a][i] - corner_outputs[b][i]; + dist += d * d; + } + dist = std::sqrt(dist); + if (dist > 0.1f) distinct_pairs++; + } + } + std::cout << " " << distinct_pairs << "/6 corner pairs are distinct (L2 > 0.1)\n"; + r.check("At least 4/6 corner pairs produce distinct outputs", distinct_pairs >= 4); + + // Check output range utilization — how much of [0,1] do the outputs cover? + float min_out = 1.f, max_out = 0.f; + for (auto& co : corner_outputs) { + for (float v : co) { + min_out = std::min(min_out, v); + max_out = std::max(max_out, v); + } + } + float range = max_out - min_out; + std::cout << " Output range utilization: " << range << " (min=" << min_out << " max=" << max_out << ")\n"; + r.check("Output range > 0.3 (sufficient variety)", range > 0.3f); + + return distinct_pairs >= 4 && range > 0.3f; +} + +// Test 6: Continuous sweep — outputs vary smoothly, not just binary +bool test_smooth_variation(SmokeTestResults& r) { + std::cout << "\n--- Test: Outputs vary smoothly across input sweep ---\n"; + nisps::IML iml(NUM_INPUTS, NUM_OUTPUTS, {16, 24, 16}); + iml.set_mode(nisps::IML::Mode::Training); + iml.randomise_weights(0.6f); + iml.set_mode(nisps::IML::Mode::Inference); + + // Sweep X from 0 to 1, fixed Y=0.5 + std::vector prev_outs(NUM_OUTPUTS, 0.f); + int smooth_steps = 0; + int total_steps = 0; + + for (float x = 0.f; x <= 1.f; x += 0.05f) { + iml.set_input(0, x); + iml.set_input(1, 0.5f); + iml.process(); + const float* outs = iml.get_outputs(); + + if (x > 0.f) { + float max_jump = 0; + for (int i = 0; i < NUM_OUTPUTS; i++) { + max_jump = std::max(max_jump, std::abs(outs[i] - prev_outs[i])); + } + // For a 0.05 input step, output jumps should be < 0.5 (smooth, not binary) + if (max_jump < 0.5f) smooth_steps++; + total_steps++; + } + for (int i = 0; i < NUM_OUTPUTS; i++) prev_outs[i] = outs[i]; + } + + float smooth_ratio = (float)smooth_steps / total_steps; + std::cout << " " << smooth_steps << "/" << total_steps << " steps were smooth (max jump < 0.5)\n"; + r.check("At least 80% of steps are smooth", smooth_ratio >= 0.8f); + return smooth_ratio >= 0.8f; +} + +int main() { + std::cout << "=== MEMLNaut VCV Module Smoke Test ===\n"; + std::cout << "Network: [" << NUM_INPUTS << "+bias, 16, 24, 16, " << NUM_OUTPUTS << "]\n"; + + SmokeTestResults r; + + test_basic_inference(r); + test_outputs_respond_to_inputs(r); + test_randomize_changes_outputs(r); + test_spread_affects_distribution(r); + test_network_expressiveness(r); + test_smooth_variation(r); + + std::cout << "\n=== Results: " << r.passed << " passed, " << r.failed << " failed ===\n"; + return r.failed > 0 ? 1 : 0; +}