ModuLisp/test/signal_engine/test_synth_wasm_abi.cpp

249 lines
9.7 KiB
C++
Raw Permalink Normal View History

// Synth WASM ABI descriptor and Worker-response correlation tests
// (synth-nodes.md §7.2 / VAL-COMP-013..017).
//
// These tests cover the versioned synth artefact ABI surface exposed from
// the generated interpreter WASM bundle:
//
// VAL-COMP-015: The synth artefact ABI is versioned and rejects
// incompatible consumers.
// VAL-COMP-016: Native and generated-WASM compilation produce
// equivalent normalised artefacts for the synth corpus.
//
// The Worker-response atomicity (VAL-COMP-013/014) is covered by the
// root Mocha integration tests. The "fresh root assets load the current
// interpreter ABI" assertion (VAL-COMP-017) is exercised by the root
// rebuild + agent-browser validator.
#define CATCH_CONFIG_MAIN
#include "../catch.hpp"
#include "src/signal_engine/signal_engine.h"
#include "src/signal_engine/synth_graph.h"
#include "src/signal_engine/synth_registry.h"
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
using namespace sig;
namespace {
struct SynthHarness {
SignalEngine engine;
SynthHarness() { engine.init_defaults(); }
EvalResult eval(const std::string& code)
{
return eval_cold(code.c_str(), static_cast<uint32_t>(code.size()), engine);
}
bool eval_ok(const std::string& code)
{
EvalResult r = eval(code);
INFO("code: " << code);
if (r.kind == EvalResult::Error && r.diagnostic_count > 0) {
for (uint8_t i = 0; i < r.diagnostic_count; i++) {
INFO("diagnostic[" << i << "]: "
<< (r.diagnostics[i].message ? r.diagnostics[i].message : ""));
}
}
return r.kind != EvalResult::Error;
}
bool eval_fails(const std::string& code)
{
EvalResult r = eval(code);
return r.kind == EvalResult::Error;
}
};
std::string snapshot_synth_artifacts(const SynthHarness& h)
{
const char* json = synth_artifacts_json(h.engine);
return json ? std::string(json) : std::string();
}
} // namespace
// ============================================================================
// VAL-COMP-015: Synth artefact ABI is versioned
// ============================================================================
TEST_CASE("synth: artefact ABI exposes a version marker",
"[synth][val-comp-015]") {
SynthHarness h;
// The published artefact JSON must carry an `abi` field naming the
// version the engine's synth-graph module advertises. Consumers read
// this before touching the payload.
//
// NOTE: the wasm wrapper wraps the body with an `abi` field. Native
// callers reach the same surface through synth_artifacts_supports_abi()
// which returns true for the engine's declared version and false for
// incompatible consumers.
REQUIRE(SYNTH_ARTIFACT_ABI_VERSION > 0);
SECTION("supports current abi") {
REQUIRE(synth_artifacts_supports_abi(SYNTH_ARTIFACT_ABI_VERSION));
}
SECTION("rejects incompatible abi") {
// A future consumer built against abi=99 must be rejected explicitly
// rather than silently misreading the layout.
REQUIRE_FALSE(synth_artifacts_supports_abi(SYNTH_ARTIFACT_ABI_VERSION + 1));
REQUIRE_FALSE(synth_artifacts_supports_abi(99));
REQUIRE_FALSE(synth_artifacts_supports_abi(0));
}
}
TEST_CASE("synth: wasm abi wrapper marks the snapshot with abi version",
"[synth][val-comp-015]") {
// Mirrors the wrapper that useq_synth_artifacts() applies in the WASM
// bundle. The C helper wraps the raw synth_artifacts_json body with an
// `abi` marker so a consumer can reject incompatible bundles up front.
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 440)"));
char buf[SYNTH_ARTIFACT_JSON_CAP + 64];
bool ok = synth_artifacts_render_abi_wrapper(
h.engine, SYNTH_ARTIFACT_ABI_VERSION, buf, sizeof(buf));
REQUIRE(ok);
std::string wrapped(buf);
REQUIRE(wrapped.find("\"abi\":") != std::string::npos);
REQUIRE(wrapped.find(std::to_string(SYNTH_ARTIFACT_ABI_VERSION))
!= std::string::npos);
// An incompatible consumer-version must be rejected by the helper up
// front (the wrapper still renders a minimal error object so the wire
// shape stays valid JSON).
bool rejected = synth_artifacts_render_abi_wrapper(
h.engine, SYNTH_ARTIFACT_ABI_VERSION + 7, buf, sizeof(buf));
REQUIRE_FALSE(rejected);
}
TEST_CASE("synth: maximum accepted shipped graph fits artifact capacity",
"[synth][capacity][serialization]") {
SynthHarness h;
for (uint16_t i = 0; i < SYNTH_MAX_NODES; i++) {
char id[32];
std::snprintf(id, sizeof(id), "node-%026u", (unsigned)i);
std::string code = "(synth \"osc/sine\" :name \"";
code += id;
code += "\" :freq 440 :amp 0.25)";
INFO("declaration " << i);
REQUIRE(h.eval_ok(code));
}
REQUIRE(h.engine.synth_graph.declaration_count() == SYNTH_MAX_NODES);
REQUIRE(h.engine.synth_graph.control_count() == SYNTH_MAX_NODES * 2);
char body[SYNTH_ARTIFACT_JSON_CAP];
REQUIRE(synth_graph_render_json(
h.engine.synth_graph, body, sizeof(body)));
REQUIRE(std::string(body).find("\"error\"") == std::string::npos);
char wrapped[SYNTH_ARTIFACT_JSON_CAP + 64];
REQUIRE(synth_artifacts_render_abi_wrapper(
h.engine, SYNTH_ARTIFACT_ABI_VERSION,
wrapped, sizeof(wrapped)));
REQUIRE(std::string(wrapped).find("artifact_error") == std::string::npos);
}
// ============================================================================
// VAL-COMP-016: Native and WASM corpus equivalence
// ============================================================================
TEST_CASE("synth: native and wasm-shaped artefacts agree on the corpus",
"[synth][val-comp-016]") {
// The generated WASM bundle serialises through synth_artifacts_json()
// (the same path as synth_graph_render_json_scratch). The native test
// path therefore exercises the exact bytes the WASM consumer will see.
//
// We verify that for every corpus form the serialised snapshot reports
// the same revision as the engine's live synth_graph and the same
// declaration/control identity names. Failed forms leave the previous
// artefact snapshot unchanged on both sides.
SynthHarness h;
struct CorpusCase {
const char* label;
const char* code;
bool should_commit;
uint16_t expected_decl_count;
uint16_t expected_ctl_count;
};
CorpusCase corpus[] = {
{ "minimum sine", "(synth \"osc/sine\" :freq 440)", true, 1, 1 },
{ "amp form", "(synth \"osc/sine\" :freq 440 :amp 0.1)", true, 1, 2 },
{ "named identity", "(synth \"osc/sine\" :name \"lead\" :freq 440)", true, 1, 1 },
{ "unknown def", "(synth \"osc/unknown\" :freq 440)", false, 0, 0 },
{ "missing required", "(synth \"osc/sine\" :amp 0.1)", false, 0, 0 },
{ "duplicate param", "(synth \"osc/sine\" :freq 440 :freq 880)", false, 0, 0 },
};
SynthRevision last_committed_rev = 0;
std::string last_committed_snap;
for (const auto& tc : corpus) {
// Reset the engine between corpus rows so each form starts from a
// clean slate and we can assert byte-stable snapshots.
SynthHarness local;
bool ok = local.eval_ok(tc.code);
REQUIRE(ok == tc.should_commit);
if (tc.should_commit) {
REQUIRE(local.engine.synth_graph.declaration_count()
== tc.expected_decl_count);
REQUIRE(local.engine.synth_graph.control_count()
== tc.expected_ctl_count);
// The snapshot must mention the registry-known def name and the
// parameter name(s) for every bound control.
std::string snap = snapshot_synth_artifacts(local);
REQUIRE(snap.find("osc/sine") != std::string::npos);
REQUIRE(snap.find("\"freq\"") != std::string::npos);
// The serialised revision must equal the live engine revision.
std::string rev_token =
"\"revision\":" + std::to_string(local.engine.synth_graph.revision);
REQUIRE(snap.find(rev_token) != std::string::npos);
last_committed_rev = local.engine.synth_graph.revision;
last_committed_snap = snap;
} else {
// A failed form must not have advanced the revision in this
// fresh local harness — the engine starts at revision 0.
REQUIRE(local.engine.synth_graph.revision == 0);
REQUIRE(local.engine.synth_graph.declaration_count() == 0);
}
}
// Sanity: at least one form in the corpus committed and produced a
// non-empty snapshot so the equivalence assertion is meaningful.
REQUIRE(last_committed_rev > 0);
REQUIRE_FALSE(last_committed_snap.empty());
}
TEST_CASE("synth: failed later form preserves prior committed snapshot",
"[synth][val-comp-016]") {
// Mirrors the WASM differential scenario: a successful synth form
// publishes a snapshot at revision R; a subsequent failed form must
// leave the snapshot byte-equal to the post-success bytes and the
// engine revision unchanged.
SynthHarness h;
REQUIRE(h.eval_ok("(synth \"osc/sine\" :name \"lead\" :freq 440 :amp 0.1)"));
SynthRevision rev_ok = h.engine.synth_graph.revision;
std::string snap_ok = snapshot_synth_artifacts(h);
REQUIRE(h.eval_fails("(synth \"osc/unknown\" :freq 110)"));
REQUIRE(h.engine.synth_graph.revision == rev_ok);
REQUIRE(snapshot_synth_artifacts(h) == snap_ok);
}