From d3bba178a9cad9105ed8af3e9d6c1a3661ea76fb Mon Sep 17 00:00:00 2001 From: 891458249 Date: Tue, 21 Apr 2026 17:00:08 +0800 Subject: [PATCH 1/5] feat(maya): adapter_core training utilities (unflatten/csv/lambda/fs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2A Slice 12 piece 1 of 5. Extends the adapter_core.hpp layer with four utilities that back the upcoming rbfmaxTrainAndSave MPxCommand. Kept pure-C++ + Maya-free so the GTest adapter suite can cover them without the devkit. Utilities (all C++14-compliant for the dual-standard constraint): * unflatten_double_array(flat, D, out) — inline-mode helper; reshapes a row-major double vector into an N x D MatrixX. Returns false with out untouched on non-positive D or shape mismatch (atomic-update contract, mirrors Phase 1 load semantics). * file_exists(path) — alias-spelling of the Slice 11 validate_json_path helper, used by the command for --force overwrite gating. * parse_csv_matrix(path, out, err) — csv-mode helper; non-noexcept (std::stod can throw); '#' line comments and blank lines skipped; enforces uniform column count; err_reason carries a short human-readable diagnostic on failure. * parse_lambda_arg(s, is_auto, value) — maps "auto"/"AUTO"/"Auto" to is_auto=true, otherwise uses std::stod end-to-end (rejects trailing garbage). Non-noexcept for the same reason. The non-inline pair lives in maya_node/src/adapter_core_csv.cpp — a new TU linked into BOTH the plugin target (via maya_node/CMakeLists) AND the adapter test target (via maya_node/tests/CMakeLists.txt). Keeping it out of the header shields the plugin's no-throw compute path from std::string-related exception specifications. Tests — 8 new TEST blocks in the D group: D1 UnflattenSimple — {1..6}, D=2 -> [[1,2],[3,4],[5,6]] D2 UnflattenBadLength — length not a multiple of D -> false; out must be untouched D3 UnflattenBadDim — D=0 and D=-1 both reject D4 ParseCsvSimple — two-row CSV parses exactly D5 ParseCsvWithComments — '#' comments + blank lines skipped D6 ParseCsvColMismatch — ragged rows produce err with "mismatch" substring D7 ParseLambdaAuto — three case variants all resolve to is_auto=true D8 ParseLambdaNumeric — numeric + trailing-garbage rejection + file_exists spot-check Random seed kSeedS12 = 0xF5BFABu reserved for future randomised additions; anchored via the H3 EXPECT_NE(kSeedS12, 0u) marker so -Wunused-variable stays quiet under /WX. Local verification (Windows 11, MSVC 19.44 Release): Step 1 adapter + Phase 1 regression: 154/154 green, 12.91 s (137 Phase 1 + 3 H + 6 C + 8 D) Co-Authored-By: Claude Opus 4.6 --- maya_node/CMakeLists.txt | 2 + .../include/rbfmax/maya/adapter_core.hpp | 65 ++++++++ maya_node/src/adapter_core_csv.cpp | 150 ++++++++++++++++++ maya_node/tests/CMakeLists.txt | 4 +- maya_node/tests/test_adapter_core.cpp | 143 ++++++++++++++++- 5 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 maya_node/src/adapter_core_csv.cpp diff --git a/maya_node/CMakeLists.txt b/maya_node/CMakeLists.txt index f6dfd51..5dde97e 100644 --- a/maya_node/CMakeLists.txt +++ b/maya_node/CMakeLists.txt @@ -32,6 +32,8 @@ if(RBF_BUILD_MAYA_NODE) add_library(rbfmax_maya_node MODULE src/mrbf_node.cpp src/plugin_main.cpp + src/adapter_core_csv.cpp # Slice 12 — non-inline CSV / lambda parsers + src/rbfmax_train_cmd.cpp # Slice 12 — rbfmaxTrainAndSave MPxCommand ) target_include_directories(rbfmax_maya_node PRIVATE diff --git a/maya_node/include/rbfmax/maya/adapter_core.hpp b/maya_node/include/rbfmax/maya/adapter_core.hpp index a8762f8..338639e 100644 --- a/maya_node/include/rbfmax/maya/adapter_core.hpp +++ b/maya_node/include/rbfmax/maya/adapter_core.hpp @@ -91,5 +91,70 @@ inline bool validate_json_path(const std::string& path) noexcept { return f.is_open(); } +// ========================================================================= +// Slice 12 training-command helpers. +// ========================================================================= +// Four pure-C++ utilities backing the rbfmaxTrainAndSave MPxCommand. +// They live here (not in the command TU) so the GTest adapter suite can +// validate CSV/inline parsing without running Maya. + +/// Unflatten a row-major `flat` double array into an N x D MatrixX. +/// Returns true on success; false if D <= 0, flat is empty, or +/// flat.size() is not a multiple of D. On failure `out` is untouched. +/// Row-major layout: flat[i*D + j] -> out(i, j). +inline bool unflatten_double_array(const std::vector& flat, + Eigen::Index D, + MatrixX& out) noexcept { + if (D <= 0) return false; + if (flat.empty()) return false; + if (static_cast(flat.size()) % D != 0) return false; + const Eigen::Index N = static_cast(flat.size()) / D; + MatrixX tmp(N, D); + for (Eigen::Index i = 0; i < N; ++i) { + for (Eigen::Index j = 0; j < D; ++j) { + tmp(i, j) = static_cast( + flat[static_cast(i * D + j)]); + } + } + out = std::move(tmp); + return true; +} + +/// Cross-platform file-existence probe. Alias spelling for +/// validate_json_path that the Slice 12 command uses when checking +/// "does the target jsonPath already exist?" (for --force gating). +inline bool file_exists(const std::string& path) noexcept { + if (path.empty()) return false; + std::ifstream f(path.c_str()); + return f.is_open(); +} + +// ---- Non-inline helpers (implementation in adapter_core_csv.cpp) ------ + +/// Parse a CSV file into an Eigen MatrixX. +/// * one non-empty, non-comment line per sample row +/// * '#' starts a line-level comment (skipped entirely) +/// * empty lines skipped +/// * column count must be uniform across all data rows +/// * cells parsed as double via std::stod; leading/trailing whitespace +/// is trimmed +/// On success returns true, writes parsed matrix to `out`, clears +/// `err_reason`. On failure returns false, leaves `out` untouched, and +/// populates `err_reason` with a short diagnostic string. +/// Not noexcept: string operations may throw std::bad_alloc, which +/// callers at the command boundary catch. +bool parse_csv_matrix(const std::string& path, + MatrixX& out, + std::string& err_reason); + +/// Parse the --lambda command flag. Accepts the literal "auto" (any +/// case) or a numeric string parseable by std::stod end-to-end. On +/// success returns true and populates out-params. On failure returns +/// false and leaves out-params untouched. +/// Not noexcept: std::stod may throw. +bool parse_lambda_arg(const std::string& s, + bool& is_auto, + Scalar& lambda_value); + } // namespace maya } // namespace rbfmax diff --git a/maya_node/src/adapter_core_csv.cpp b/maya_node/src/adapter_core_csv.cpp new file mode 100644 index 0000000..7d262fd --- /dev/null +++ b/maya_node/src/adapter_core_csv.cpp @@ -0,0 +1,150 @@ +// ============================================================================= +// maya_node/src/adapter_core_csv.cpp — Phase 2A Slice 12 +// ----------------------------------------------------------------------------- +// Non-inline implementation of the Slice 12 training-command helpers that +// are documented in adapter_core.hpp. These are kept out of the header +// (and out of the node plugin's hot path) because they use std::string / +// std::stod / std::ifstream which may throw and thus cannot be noexcept. +// +// Deliberately Maya-free so the adapter GTest suite links this TU without +// pulling in the devkit. +// ============================================================================= +#include "rbfmax/maya/adapter_core.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rbfmax { +namespace maya { + +namespace { + +// Trim leading + trailing ASCII whitespace. Returns an independent +// std::string (no pointers into the source). +std::string strip(const std::string& s) { + auto is_ws = [](unsigned char c) { return std::isspace(c) != 0; }; + auto begin = std::find_if_not(s.begin(), s.end(), is_ws); + auto end = std::find_if_not(s.rbegin(), s.rend(), is_ws).base(); + return (begin < end) ? std::string(begin, end) : std::string(); +} + +// Split a CSV line on ',' and trim each cell. Always returns at least +// one element (an empty string, for lines containing no comma). +std::vector split_csv_row(const std::string& line) { + std::vector cells; + std::string cur; + for (char c : line) { + if (c == ',') { + cells.push_back(strip(cur)); + cur.clear(); + } else { + cur.push_back(c); + } + } + cells.push_back(strip(cur)); + return cells; +} + +} // namespace + +bool parse_csv_matrix(const std::string& path, + MatrixX& out, + std::string& err_reason) { + std::ifstream f(path.c_str()); + if (!f.is_open()) { + err_reason = "cannot open csv file: " + path; + return false; + } + + std::vector> rows; + Eigen::Index cols = -1; + std::string line; + std::size_t line_no = 0; + + while (std::getline(f, line)) { + ++line_no; + const std::string trimmed = strip(line); + if (trimmed.empty()) continue; + if (trimmed[0] == '#') continue; + + const auto cells = split_csv_row(line); + if (cols < 0) { + cols = static_cast(cells.size()); + } else if (static_cast(cells.size()) != cols) { + err_reason = "csv column count mismatch at line " + + std::to_string(line_no); + return false; + } + + std::vector parsed; + parsed.reserve(cells.size()); + for (const auto& cell : cells) { + if (cell.empty()) { + err_reason = "empty cell at line " + + std::to_string(line_no); + return false; + } + try { + std::size_t pos = 0; + const double v = std::stod(cell, &pos); + if (pos != cell.size()) { + err_reason = + "non-numeric trailing characters at line " + + std::to_string(line_no) + ": \"" + cell + "\""; + return false; + } + parsed.push_back(static_cast(v)); + } catch (const std::exception&) { + err_reason = "cannot parse number at line " + + std::to_string(line_no) + ": \"" + cell + "\""; + return false; + } + } + rows.push_back(std::move(parsed)); + } + + if (rows.empty() || cols <= 0) { + err_reason = "csv file has no data rows: " + path; + return false; + } + + MatrixX mat(static_cast(rows.size()), cols); + for (Eigen::Index i = 0; i < mat.rows(); ++i) { + for (Eigen::Index j = 0; j < cols; ++j) { + mat(i, j) = rows[static_cast(i)] + [static_cast(j)]; + } + } + out = std::move(mat); + err_reason.clear(); + return true; +} + +bool parse_lambda_arg(const std::string& s, + bool& is_auto, + Scalar& lambda_value) { + if (s == "auto" || s == "AUTO" || s == "Auto") { + is_auto = true; + lambda_value = Scalar(0); + return true; + } + try { + std::size_t pos = 0; + const double v = std::stod(s, &pos); + if (pos != s.size()) return false; + is_auto = false; + lambda_value = static_cast(v); + return true; + } catch (const std::exception&) { + return false; + } +} + +} // namespace maya +} // namespace rbfmax diff --git a/maya_node/tests/CMakeLists.txt b/maya_node/tests/CMakeLists.txt index 6f2baa9..f8472e5 100644 --- a/maya_node/tests/CMakeLists.txt +++ b/maya_node/tests/CMakeLists.txt @@ -16,7 +16,9 @@ endif() include(GoogleTest) -add_executable(test_adapter_core test_adapter_core.cpp) +add_executable(test_adapter_core + test_adapter_core.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/adapter_core_csv.cpp) target_link_libraries(test_adapter_core PRIVATE rbfmax::kernel diff --git a/maya_node/tests/test_adapter_core.cpp b/maya_node/tests/test_adapter_core.cpp index 21c3dfe..58861af 100644 --- a/maya_node/tests/test_adapter_core.cpp +++ b/maya_node/tests/test_adapter_core.cpp @@ -42,18 +42,24 @@ namespace { +using rbfmax::MatrixX; using rbfmax::Scalar; using rbfmax::VectorX; using rbfmax::maya::double_vector_to_eigen; using rbfmax::maya::eigen_to_double_vector; +using rbfmax::maya::file_exists; using rbfmax::maya::hello_transform; +using rbfmax::maya::parse_csv_matrix; +using rbfmax::maya::parse_lambda_arg; +using rbfmax::maya::unflatten_double_array; using rbfmax::maya::validate_json_path; // Seed reserved (since Slice 10A) for randomised adapter tests. Slice 11's -// C1-C6 are deterministic and do not use it; the anchor TEST in H3 keeps -// the symbol alive under -Wunused-variable. -constexpr std::uint32_t kSeed = 0xF5BFA9u; // Slice 10A seed -constexpr std::uint32_t kSeedS11 = 0xF5BFAAu; // Slice 11 seed reserved +// C1-C6 and Slice 12's D1-D8 are deterministic and do not use them; the +// anchor TEST in H3 keeps the symbols alive under -Wunused-variable. +constexpr std::uint32_t kSeed = 0xF5BFA9u; // Slice 10A seed +constexpr std::uint32_t kSeedS11 = 0xF5BFAAu; // Slice 11 seed reserved +constexpr std::uint32_t kSeedS12 = 0xF5BFABu; // Slice 12 seed reserved // --------------------------------------------------------------------- // Cross-platform temp-file helper, mirroring the Slice 08 pattern in @@ -118,10 +124,11 @@ TEST(HelloTransform, H3_EvenFunctionUnderSignFlip) { EXPECT_EQ(hello_transform(x), hello_transform(-x)) << "x = " << x; } - // Anchor kSeed / kSeedS11 so -Wunused-variable does not fire - // under /WX until Slice 11+ tests actually consume them. + // Anchor kSeed / kSeedS11 / kSeedS12 so -Wunused-variable does not + // fire under /WX until Slice 11+ tests actually consume them. EXPECT_NE(kSeed, 0u); EXPECT_NE(kSeedS11, 0u); + EXPECT_NE(kSeedS12, 0u); } // ============================================================================= @@ -206,3 +213,127 @@ TEST(AdapterMarshalling, C6_DoubleToEigenPreservesPrecision) { EXPECT_EQ(back[i], src[i]) << "i=" << i; // exact ==, not NEAR } } + +// ============================================================================= +// Slice 12 — D group (8): training-command helpers +// ============================================================================= +// +// These tests cover the four pure-C++ utilities that back the +// rbfmaxTrainAndSave MPxCommand: +// * unflatten_double_array (inline mode: flat list -> N x D matrix) +// * parse_csv_matrix (csv mode: file -> matrix) +// * parse_lambda_arg ("auto" or numeric) +// * file_exists (same contract as validate_json_path) +// +// All deterministic; kSeedS12 reserved for future randomised additions. + +// D1 — Simple unflatten: {1,2,3,4,5,6} with D=2 -> 3x2 matrix. +TEST(AdapterTrainInline, D1_UnflattenSimple) { + const std::vector flat = {1, 2, 3, 4, 5, 6}; + MatrixX m; + ASSERT_TRUE(unflatten_double_array(flat, 2, m)); + ASSERT_EQ(m.rows(), 3); + ASSERT_EQ(m.cols(), 2); + EXPECT_EQ(m(0, 0), 1.0); EXPECT_EQ(m(0, 1), 2.0); + EXPECT_EQ(m(1, 0), 3.0); EXPECT_EQ(m(1, 1), 4.0); + EXPECT_EQ(m(2, 0), 5.0); EXPECT_EQ(m(2, 1), 6.0); +} + +// D2 — size not a multiple of D: must fail and leave out untouched. +TEST(AdapterTrainInline, D2_UnflattenBadLength) { + const std::vector flat = {1, 2, 3}; + MatrixX sentinel(5, 7); // pre-existing shape + MatrixX m = sentinel; + EXPECT_FALSE(unflatten_double_array(flat, 2, m)); + // On failure, out is untouched (preserves atomic-update contract). + EXPECT_EQ(m.rows(), 5); + EXPECT_EQ(m.cols(), 7); +} + +// D3 — non-positive D: must fail. +TEST(AdapterTrainInline, D3_UnflattenBadDim) { + const std::vector flat = {1, 2, 3, 4}; + MatrixX m; + EXPECT_FALSE(unflatten_double_array(flat, 0, m)); + EXPECT_FALSE(unflatten_double_array(flat, -1, m)); +} + +// D4 — simple CSV: "1.0,2.0\n3.0,4.0\n" -> 2x2 matrix. +TEST(AdapterTrainCsv, D4_ParseCsvSimple) { + TempFile tf("d4"); + tf.write("1.0,2.0\n3.0,4.0\n"); + MatrixX m; + std::string err; + ASSERT_TRUE(parse_csv_matrix(tf.path(), m, err)) << "err=" << err; + ASSERT_EQ(m.rows(), 2); + ASSERT_EQ(m.cols(), 2); + EXPECT_EQ(m(0, 0), 1.0); EXPECT_EQ(m(0, 1), 2.0); + EXPECT_EQ(m(1, 0), 3.0); EXPECT_EQ(m(1, 1), 4.0); + EXPECT_TRUE(err.empty()); +} + +// D5 — comments + blank lines are skipped; data rows are collected. +TEST(AdapterTrainCsv, D5_ParseCsvWithCommentsAndBlankLines) { + TempFile tf("d5"); + tf.write("# header comment\n" + "1,2\n" + "\n" + "# another comment\n" + "3,4\n" + "\n"); + MatrixX m; + std::string err; + ASSERT_TRUE(parse_csv_matrix(tf.path(), m, err)) << "err=" << err; + ASSERT_EQ(m.rows(), 2); + ASSERT_EQ(m.cols(), 2); + EXPECT_EQ(m(0, 0), 1.0); EXPECT_EQ(m(0, 1), 2.0); + EXPECT_EQ(m(1, 0), 3.0); EXPECT_EQ(m(1, 1), 4.0); +} + +// D6 — column-count mismatch across rows: fail, err_reason says so. +TEST(AdapterTrainCsv, D6_ParseCsvColMismatch) { + TempFile tf("d6"); + tf.write("1,2\n3,4,5\n"); + MatrixX m; + std::string err; + EXPECT_FALSE(parse_csv_matrix(tf.path(), m, err)); + EXPECT_NE(err.find("mismatch"), std::string::npos) + << "err should contain 'mismatch', got: " << err; +} + +// D7 — "auto" / "AUTO" / "Auto" all resolve to is_auto=true. +TEST(AdapterTrainLambda, D7_ParseLambdaAuto) { + const std::string variants[] = {"auto", "AUTO", "Auto"}; + for (const auto& v : variants) { + bool is_auto = false; + Scalar lambda_value = -1.0; + ASSERT_TRUE(parse_lambda_arg(v, is_auto, lambda_value)) + << "variant: " << v; + EXPECT_TRUE(is_auto) << "variant: " << v; + } +} + +// D8 — numeric strings parse end-to-end and set is_auto=false. +TEST(AdapterTrainLambda, D8_ParseLambdaNumeric) { + bool is_auto = true; + Scalar lambda_value = 0; + ASSERT_TRUE(parse_lambda_arg("1e-6", is_auto, lambda_value)); + EXPECT_FALSE(is_auto); + EXPECT_DOUBLE_EQ(lambda_value, 1e-6); + + ASSERT_TRUE(parse_lambda_arg("0.001", is_auto, lambda_value)); + EXPECT_FALSE(is_auto); + EXPECT_DOUBLE_EQ(lambda_value, 0.001); + + // Trailing garbage should cause rejection. + EXPECT_FALSE(parse_lambda_arg("1e-6xyz", is_auto, lambda_value)); + EXPECT_FALSE(parse_lambda_arg("not_a_number", is_auto, lambda_value)); + + // file_exists sanity-check alongside (single D-group test keeps the + // matrix small; the helper is trivially delegated to + // validate_json_path). + EXPECT_FALSE(file_exists("")); + TempFile tf("d8_exists"); + tf.write("x"); + EXPECT_TRUE(file_exists(tf.path())); +} From 53ff9d7af27ea3a75204e8bbf43b79cb78fd164a Mon Sep 17 00:00:00 2001 From: 891458249 Date: Tue, 21 Apr 2026 17:30:16 +0800 Subject: [PATCH 2/5] feat(maya): rbfmaxTrainAndSave MPxCommand wiring Phase 1 fit + save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2A Slice 12 piece 2 of 5. Adds the MPxCommand that closes the Slice 11 "trainers cannot train from inside Maya" gap. Two mutually exclusive input modes: 1. Inline mode (flag-driven double lists): cmds.rbfmaxTrainAndSave( centers=[x0, y0, x1, y1, ...], targets=[t0, t1, ...], inputDim=2, outputDim=1, jsonPath="...", kernel="Gaussian", epsilon=1.0, polyDegree=-1, force=True, **{"lambda":"1e-6"}) 2. CSV mode (path-driven, the scalable option): cmds.rbfmaxTrainAndSave( centersFile="centers.csv", targetsFile="targets.csv", jsonPath="...", kernel="Gaussian", epsilon=1.0, ...) Both paths call Phase 1 solver::fit under the hood, then RBFInterpolator::save to produce a schema-v1 JSON that the Slice 11 mRBFNode can load. On success returns the jsonPath string so callers can chain into setAttr (setAttr .jsonPath `rbfmaxTrainAndSave ...`). 12 flags, all validated to register in both Maya 2022 and Maya 2025. ------------------------------------------------------------------ F5 (Slice 12 executor catch) — long flag names must be >= 4 chars ------------------------------------------------------------------ Maya's MSyntax::addFlag silently returns kFailure "Unexpected Internal Failure" for long flag names shorter than 4 characters. First drafts used spec's literal -dim / -eps; both were silently dropped, manifesting at Python bind time as `TypeError: Invalid flag 'eps'`. Discovered via a diagnostic wrapper around addFlag that surfaced the kFailure return, then confirmed by help() listing 10 of 12 flags registered. Renamed to -inputDim and -epsilon (and Python keyword args match). Rule documented in the MSyntax comment block and in DEVLOG R-30. Other MSyntax findings from the same debugging: * MArgDatabase::getFlagArgumentList(flag, i, MArgList&) is the correct way to read each use of a multi-use kDouble flag — getFlagArgument(flag, i, double&) exists but reads the i-th *flag's* first argument, not what we want. Multi-use pattern wraps one double per flag-use into an MArgList which the reader extracts index 0 from. Plugin_main.cpp: * Added RbfmaxTrainCmd registerCommand + deregisterCommand wiring. * initializePlugin: registerNode -> registerCommand with a best-effort deregisterNode rollback if the command registration fails (keeps loadPlugin atomic). * uninitializePlugin: deregisterCommand first, then deregisterNode; both errors are surfaced but unwinding continues. Smoke coverage (smoke_train.py, 4 scenarios): S1 CSV-mode train + mRBFNode load + predict bit-identical to Slice 11 tiny_rbf_expected.json (all 3 queries err < 1e-10) S2 Inline-mode train, same bit-identity check S3 --force=False against existing file raises RuntimeError with "file exists" substring S4 kernel="Nonsense" raises RuntimeError with "unknown kernel" or "nonsense" substring Fixtures (committed): maya_node/tests/smoke/fixtures/tiny_train_centers.csv (4x2) maya_node/tests/smoke/fixtures/tiny_train_targets.csv (4x1) Both mirror the Slice 11 tiny_rbf.json data so the train-then-predict bit-identity assertion is meaningful. Local verification (Windows 11, MSVC 19.44 Release): Step 2a Maya 2022 plugin: 501 760 bytes, 0 warn 0 err Step 2b Maya 2025 plugin: 501 760 bytes, 0 warn 0 err (byte- identical, matches the Slice 11 version-matrix ABI-agnosticism) Step 3a Maya 2022 smokes: hellonode/predict/train all exit 0 Step 3b Maya 2025 smokes: all exit 0, bit-identical to 2022 Co-Authored-By: Claude Opus 4.6 --- .../include/rbfmax/maya/rbfmax_train_cmd.hpp | 58 +++ maya_node/src/plugin_main.cpp | 38 +- maya_node/src/rbfmax_train_cmd.cpp | 348 ++++++++++++++++++ .../smoke/fixtures/tiny_train_centers.csv | 7 + .../smoke/fixtures/tiny_train_targets.csv | 6 + maya_node/tests/smoke/smoke_train.py | 198 ++++++++++ 6 files changed, 650 insertions(+), 5 deletions(-) create mode 100644 maya_node/include/rbfmax/maya/rbfmax_train_cmd.hpp create mode 100644 maya_node/src/rbfmax_train_cmd.cpp create mode 100644 maya_node/tests/smoke/fixtures/tiny_train_centers.csv create mode 100644 maya_node/tests/smoke/fixtures/tiny_train_targets.csv create mode 100644 maya_node/tests/smoke/smoke_train.py diff --git a/maya_node/include/rbfmax/maya/rbfmax_train_cmd.hpp b/maya_node/include/rbfmax/maya/rbfmax_train_cmd.hpp new file mode 100644 index 0000000..4ab4da3 --- /dev/null +++ b/maya_node/include/rbfmax/maya/rbfmax_train_cmd.hpp @@ -0,0 +1,58 @@ +// ============================================================================= +// rbfmax/maya/rbfmax_train_cmd.hpp — Phase 2A Slice 12 +// ----------------------------------------------------------------------------- +// MEL / Python command `rbfmaxTrainAndSave` — closes the Slice 11 gap of +// "users cannot train from inside Maya". Two input modes (mutually +// exclusive): +// +// 1. inline (flag-driven double lists): +// cmds.rbfmaxTrainAndSave( +// centers=[x0, y0, x1, y1, ...], +// targets=[t0, t1, ...], +// dim=2, outputDim=1, +// jsonPath="C:/path/out.json", +// kernel="Gaussian", eps=1.0, polyDegree=-1, +// **{"lambda": "auto"}, force=True) +// +// 2. csv (path-driven, Maya doubleArray flags are MEL-hostile for +// large N): +// cmds.rbfmaxTrainAndSave( +// centersFile="C:/centers.csv", +// targetsFile="C:/targets.csv", +// jsonPath="C:/out.json", ...) +// +// On success, the command returns the output jsonPath string and writes +// the full schema-v1 Phase 1 fit via rbfmax::io_json. On failure it +// calls MGlobal::displayError with a descriptive message and returns +// MS::kFailure (Python binding raises RuntimeError). +// +// Not undoable (the command writes a file; undo would need to remember +// the previous file contents, out of scope for Slice 12). +// ============================================================================= +#pragma once + +#include +#include +#include +#include +#include + +namespace rbfmax { +namespace maya { + +class RbfmaxTrainCmd : public MPxCommand { +public: + RbfmaxTrainCmd() = default; + ~RbfmaxTrainCmd() override = default; + + MStatus doIt(const MArgList& args) override; + bool isUndoable() const override { return false; } + + static void* creator() { return new RbfmaxTrainCmd(); } + static MSyntax newSyntax(); + + static const MString kCommandName; // "rbfmaxTrainAndSave" +}; + +} // namespace maya +} // namespace rbfmax diff --git a/maya_node/src/plugin_main.cpp b/maya_node/src/plugin_main.cpp index 6869d16..ea64618 100644 --- a/maya_node/src/plugin_main.cpp +++ b/maya_node/src/plugin_main.cpp @@ -1,14 +1,17 @@ // ============================================================================= -// maya_node/src/plugin_main.cpp — Phase 2A Slice 10A +// maya_node/src/plugin_main.cpp — Phase 2A Slice 12 // ----------------------------------------------------------------------------- -// initializePlugin / uninitializePlugin entry points. Registers the -// Slice 10A mRBFNode skeleton with Maya. +// initializePlugin / uninitializePlugin entry points. Registers: +// * Slice 10A : mRBFNode skeleton (HelloNode transform) +// * Slice 11 : mRBFNode real predict via JSON-path load +// * Slice 12 : rbfmaxTrainAndSave MPxCommand // ============================================================================= #include #include #include "rbfmax/maya/mrbf_node.hpp" #include "rbfmax/maya/plugin_info.hpp" +#include "rbfmax/maya/rbfmax_train_cmd.hpp" // Maya 2022+ wraps MStatus (and other API types) in an inline namespace // ``Autodesk::Maya::OpenMaya`` for ABI versioning. Under MSVC @@ -29,6 +32,7 @@ RBFMAX_PLUGIN_EXPORT MStatus initializePlugin(MObject obj) { rbfmax::maya::kPluginVendor, rbfmax::maya::kPluginVersion, "Any"); + MStatus status = plugin.registerNode( rbfmax::maya::mRBFNode::kTypeName, rbfmax::maya::mRBFNode::kTypeId, @@ -36,15 +40,39 @@ RBFMAX_PLUGIN_EXPORT MStatus initializePlugin(MObject obj) { rbfmax::maya::mRBFNode::initialize); if (!status) { status.perror("registerNode mRBFNode"); + return status; + } + + // Slice 12 — rbfmaxTrainAndSave MPxCommand. + status = plugin.registerCommand( + rbfmax::maya::RbfmaxTrainCmd::kCommandName, + rbfmax::maya::RbfmaxTrainCmd::creator, + rbfmax::maya::RbfmaxTrainCmd::newSyntax); + if (!status) { + status.perror("registerCommand rbfmaxTrainAndSave"); + // Roll back the node registration so loadPlugin is atomic. + plugin.deregisterNode(rbfmax::maya::mRBFNode::kTypeId); + return status; } + return status; } RBFMAX_PLUGIN_EXPORT MStatus uninitializePlugin(MObject obj) { MFnPlugin plugin(obj); - MStatus status = plugin.deregisterNode(rbfmax::maya::mRBFNode::kTypeId); + + MStatus status = plugin.deregisterCommand( + rbfmax::maya::RbfmaxTrainCmd::kCommandName); if (!status) { - status.perror("deregisterNode mRBFNode"); + status.perror("deregisterCommand rbfmaxTrainAndSave"); + // Keep unwinding so the node also deregisters. + } + + MStatus nodeStatus = plugin.deregisterNode( + rbfmax::maya::mRBFNode::kTypeId); + if (!nodeStatus) { + nodeStatus.perror("deregisterNode mRBFNode"); + return nodeStatus; } return status; } diff --git a/maya_node/src/rbfmax_train_cmd.cpp b/maya_node/src/rbfmax_train_cmd.cpp new file mode 100644 index 0000000..3048b6d --- /dev/null +++ b/maya_node/src/rbfmax_train_cmd.cpp @@ -0,0 +1,348 @@ +// ============================================================================= +// maya_node/src/rbfmax_train_cmd.cpp — Phase 2A Slice 12 +// ----------------------------------------------------------------------------- +// Implementation of rbfmaxTrainAndSave. See rbfmax_train_cmd.hpp for +// contract / usage; see DEVLOG Slice 12 for design-decision rationale. +// ============================================================================= +#include "rbfmax/maya/rbfmax_train_cmd.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "rbfmax/interpolator.hpp" +#include "rbfmax/kernel_functions.hpp" +#include "rbfmax/maya/adapter_core.hpp" +#include "rbfmax/solver.hpp" +#include "rbfmax/types.hpp" + +namespace rbfmax { +namespace maya { + +// ----------------------------------------------------------------------------- +// Flag spellings (short / long) +// ----------------------------------------------------------------------------- + +namespace { + +const char* const kF_centers = "-c"; +const char* const kF_centersL = "-centers"; +const char* const kF_targets = "-t"; +const char* const kF_targetsL = "-targets"; + +const char* const kF_centersFile = "-cf"; +const char* const kF_centersFileL = "-centersFile"; +const char* const kF_targetsFile = "-tf"; +const char* const kF_targetsFileL = "-targetsFile"; + +// F5 findings (Slice 12 executor run): Maya's MSyntax::addFlag silently +// rejects short names whose first character matches a Maya-reserved +// global-command prefix: -d (debug), -e (edit), -h (help), -q (query). +// Observed: -di and -ep both returned kFailure "Unexpected Internal +// Failure" despite looking like valid non-reserved abbreviations. +// Workaround: start every short name with a letter that is not in +// {d,e,h,q}. We use "ki" (kernel input dim), "ks" (kernel scale=eps), +// etc., to stay mnemonic while avoiding the reserved first letters. +// F5 root cause (Slice 12): Maya's addFlag silently rejects long names +// shorter than 4 characters — "dim" (3) and "eps" (3) both failed. +// All working long names in the canonical Autodesk samples and in +// this command are >= 4 chars. Renamed to inputDim / epsilon. +const char* const kF_dim = "-idm"; +const char* const kF_dimL = "-inputDim"; +const char* const kF_outputDim = "-od"; +const char* const kF_outputDimL = "-outputDim"; + +const char* const kF_jsonPath = "-jp"; +const char* const kF_jsonPathL = "-jsonPath"; + +const char* const kF_kernel = "-kn"; +const char* const kF_kernelL = "-kernel"; +const char* const kF_eps = "-ep"; +const char* const kF_epsL = "-epsilon"; +const char* const kF_polyDegree = "-pd"; +const char* const kF_polyDegreeL = "-polyDegree"; +const char* const kF_lambda = "-lm"; +const char* const kF_lambdaL = "-lambda"; +const char* const kF_force = "-fo"; +const char* const kF_forceL = "-force"; + +// Emit descriptive MGlobal error + convert to MS::kFailure. +MStatus fail(const std::string& msg) { + MGlobal::displayError( + MString("rbfmaxTrainAndSave: ") + MString(msg.c_str())); + return MS::kFailure; +} + +const char* fit_status_name(solver::FitStatus s) noexcept { + switch (s) { + case solver::FitStatus::OK: return "OK"; + case solver::FitStatus::INSUFFICIENT_SAMPLES: return "INSUFFICIENT_SAMPLES"; + case solver::FitStatus::SINGULAR_MATRIX: return "SINGULAR_MATRIX"; + case solver::FitStatus::INVALID_INPUT: return "INVALID_INPUT"; + } + return "Unknown"; +} + +} // namespace + +const MString RbfmaxTrainCmd::kCommandName{"rbfmaxTrainAndSave"}; + +// ----------------------------------------------------------------------------- +// MSyntax +// ----------------------------------------------------------------------------- + +MSyntax RbfmaxTrainCmd::newSyntax() { + MSyntax s; + + // Inline-mode flags: -centers / -targets are multi-use kDouble so a + // Python list is accepted per Maya's script binding conventions. + s.addFlag(kF_centers, kF_centersL, MSyntax::kDouble); + s.makeFlagMultiUse(kF_centers); + s.addFlag(kF_targets, kF_targetsL, MSyntax::kDouble); + s.makeFlagMultiUse(kF_targets); + + // CSV-mode flags. + s.addFlag(kF_centersFile, kF_centersFileL, MSyntax::kString); + s.addFlag(kF_targetsFile, kF_targetsFileL, MSyntax::kString); + + // Inline-mode shape. NOTE: long names must be >= 4 chars — Maya's + // MSyntax::addFlag silently rejects 3-char long names. "dim" and + // "eps" were caught by F5; renamed to "inputDim" / "epsilon". + s.addFlag(kF_dim, kF_dimL, MSyntax::kLong); + s.addFlag(kF_outputDim, kF_outputDimL, MSyntax::kLong); + + // Shared flags. + s.addFlag(kF_jsonPath, kF_jsonPathL, MSyntax::kString); + s.addFlag(kF_kernel, kF_kernelL, MSyntax::kString); + s.addFlag(kF_eps, kF_epsL, MSyntax::kDouble); + s.addFlag(kF_polyDegree, kF_polyDegreeL, MSyntax::kLong); + s.addFlag(kF_lambda, kF_lambdaL, MSyntax::kString); + s.addFlag(kF_force, kF_forceL, MSyntax::kBoolean); + + return s; +} + +// ----------------------------------------------------------------------------- +// Multi-use flag reader +// ----------------------------------------------------------------------------- + +namespace { + +// Read every use of a multi-use kDouble flag into out. Returns kSuccess +// even if the flag was not supplied (out stays empty). Maya's +// MArgParser::getFlagArgument reads ONE double from each flag use; for +// a Python list arg Maya translates every element into its own flag use. +MStatus read_multi_double(const MArgDatabase& adb, + const char* flag_short, + std::vector& out) { + out.clear(); + const unsigned int n = adb.numberOfFlagUses(flag_short); + out.reserve(n); + for (unsigned int i = 0; i < n; ++i) { + MArgList argList; + MStatus st = adb.getFlagArgumentList(flag_short, i, argList); + if (!st) return st; + // argList for a kDouble flag has one double at index 0. + double v = 0.0; + st = argList.get(0u, v); + if (!st) return st; + out.push_back(v); + } + return MS::kSuccess; +} + +} // namespace + +// ----------------------------------------------------------------------------- +// doIt — the whole pipeline +// ----------------------------------------------------------------------------- + +MStatus RbfmaxTrainCmd::doIt(const MArgList& args) { + MStatus st; + MArgDatabase adb(syntax(), args, &st); + if (!st) { + return fail(std::string("cannot parse arguments: ") + st.errorString().asChar()); + } + + // ---- Required: jsonPath ------------------------------------------------- + if (!adb.isFlagSet(kF_jsonPath)) { + return fail("missing required flag -jsonPath / -jp"); + } + MString mJsonPath; + adb.getFlagArgument(kF_jsonPath, 0, mJsonPath); + const std::string jsonPath = mJsonPath.asChar(); + if (jsonPath.empty()) { + return fail("-jsonPath must not be empty"); + } + + // ---- Shared defaults ---------------------------------------------------- + std::string kernel_str = "Gaussian"; + double eps = 1.0; + int poly_degree = -1; + std::string lambda_str = "auto"; + bool force = false; + + if (adb.isFlagSet(kF_kernel)) { + MString v; adb.getFlagArgument(kF_kernel, 0, v); + kernel_str = v.asChar(); + } + if (adb.isFlagSet(kF_eps)) { + adb.getFlagArgument(kF_eps, 0, eps); + } + if (adb.isFlagSet(kF_polyDegree)) { + adb.getFlagArgument(kF_polyDegree, 0, poly_degree); + } + if (adb.isFlagSet(kF_lambda)) { + MString v; adb.getFlagArgument(kF_lambda, 0, v); + lambda_str = v.asChar(); + } + if (adb.isFlagSet(kF_force)) { + adb.getFlagArgument(kF_force, 0, force); + } + + // ---- Mode detection ----------------------------------------------------- + const bool has_inline = + adb.isFlagSet(kF_centers) || adb.isFlagSet(kF_targets); + const bool has_csv = + adb.isFlagSet(kF_centersFile) || adb.isFlagSet(kF_targetsFile); + if (has_inline && has_csv) { + return fail("modes are mutually exclusive: pass either " + "-centers/-targets or -centersFile/-targetsFile, " + "not both"); + } + if (!has_inline && !has_csv) { + return fail("must supply either -centers and -targets (inline " + "mode) or -centersFile and -targetsFile (csv mode)"); + } + + // ---- Load data ---------------------------------------------------------- + MatrixX centers; + MatrixX targets; + + try { + if (has_csv) { + if (!adb.isFlagSet(kF_centersFile) || !adb.isFlagSet(kF_targetsFile)) { + return fail("csv mode requires both -centersFile and " + "-targetsFile"); + } + MString mC, mT; + adb.getFlagArgument(kF_centersFile, 0, mC); + adb.getFlagArgument(kF_targetsFile, 0, mT); + std::string err_reason; + if (!parse_csv_matrix(mC.asChar(), centers, err_reason)) { + return fail("centers csv parse failed: " + err_reason); + } + if (!parse_csv_matrix(mT.asChar(), targets, err_reason)) { + return fail("targets csv parse failed: " + err_reason); + } + if (centers.rows() != targets.rows()) { + return fail("csv row count mismatch: centers has " + + std::to_string(static_cast(centers.rows())) + + " rows, targets has " + + std::to_string(static_cast(targets.rows()))); + } + } else { + // Inline mode. + if (!adb.isFlagSet(kF_dim) || !adb.isFlagSet(kF_outputDim)) { + return fail("inline mode requires both -dim and -outputDim"); + } + int dim = 0, odim = 0; + adb.getFlagArgument(kF_dim, 0, dim); + adb.getFlagArgument(kF_outputDim, 0, odim); + if (dim <= 0 || odim <= 0) { + return fail("-dim and -outputDim must be positive"); + } + + std::vector c_flat, t_flat; + st = read_multi_double(adb, kF_centers, c_flat); + if (!st) return fail(std::string("cannot read -centers: ") + + st.errorString().asChar()); + st = read_multi_double(adb, kF_targets, t_flat); + if (!st) return fail(std::string("cannot read -targets: ") + + st.errorString().asChar()); + + if (!unflatten_double_array(c_flat, + static_cast(dim), centers)) { + return fail("-centers flat length " + + std::to_string(c_flat.size()) + + " is not a multiple of -dim " + + std::to_string(dim)); + } + if (!unflatten_double_array(t_flat, + static_cast(odim), targets)) { + return fail("-targets flat length " + + std::to_string(t_flat.size()) + + " is not a multiple of -outputDim " + + std::to_string(odim)); + } + if (centers.rows() != targets.rows()) { + return fail("row count mismatch: centers has " + + std::to_string(static_cast(centers.rows())) + + " samples, targets has " + + std::to_string(static_cast(targets.rows()))); + } + } + } catch (const std::exception& ex) { + return fail(std::string("data load exception: ") + ex.what()); + } + + // ---- Validate kernel string -------------------------------------------- + KernelType ktype; + if (!kernel_type_from_string(kernel_str.c_str(), ktype)) { + return fail("unknown kernel: \"" + kernel_str + "\" (valid: " + "Linear | Cubic | Quintic | ThinPlateSpline | " + "Gaussian | InverseMultiquadric)"); + } + + // ---- --force gate on existing file ------------------------------------- + if (!force && file_exists(jsonPath)) { + return fail("file exists; pass -force true to overwrite: " + jsonPath); + } + + // ---- Parse lambda ------------------------------------------------------- + bool lambda_auto = false; + Scalar lambda_value = 0; + try { + if (!parse_lambda_arg(lambda_str, lambda_auto, lambda_value)) { + return fail("cannot parse -lambda: \"" + lambda_str + + "\" (expected \"auto\" or a numeric like \"1e-6\")"); + } + } catch (const std::exception& ex) { + return fail(std::string("lambda parse exception: ") + ex.what()); + } + + // ---- Construct + fit + save -------------------------------------------- + try { + InterpolatorOptions opts(KernelParams(ktype, static_cast(eps))); + opts.poly_degree = poly_degree; + + RBFInterpolator rbf(opts); + solver::FitStatus fs = lambda_auto + ? rbf.fit(centers, targets, solver::kLambdaAuto) + : rbf.fit(centers, targets, lambda_value); + + if (fs != solver::FitStatus::OK) { + return fail(std::string("fit failed: ") + fit_status_name(fs)); + } + + if (!rbf.save(jsonPath)) { + return fail("save failed (could not write schema-v1 JSON to): " + + jsonPath); + } + } catch (const std::exception& ex) { + return fail(std::string("fit/save exception: ") + ex.what()); + } + + setResult(MString(jsonPath.c_str())); + return MS::kSuccess; +} + +} // namespace maya +} // namespace rbfmax diff --git a/maya_node/tests/smoke/fixtures/tiny_train_centers.csv b/maya_node/tests/smoke/fixtures/tiny_train_centers.csv new file mode 100644 index 0000000..10829fa --- /dev/null +++ b/maya_node/tests/smoke/fixtures/tiny_train_centers.csv @@ -0,0 +1,7 @@ +# Slice 12 training fixture — 4 corners of the unit square (N=4, D=2). +# Mirrors tiny_rbf.json's centers so rbfmaxTrainAndSave output can be +# cross-checked against the Slice 11 expected output table. +0.0,0.0 +1.0,0.0 +0.0,1.0 +1.0,1.0 diff --git a/maya_node/tests/smoke/fixtures/tiny_train_targets.csv b/maya_node/tests/smoke/fixtures/tiny_train_targets.csv new file mode 100644 index 0000000..f9b79ed --- /dev/null +++ b/maya_node/tests/smoke/fixtures/tiny_train_targets.csv @@ -0,0 +1,6 @@ +# Slice 12 training fixture — 4 target values (N=4, M=1). +# target = x + y, matches tiny_rbf.json. +0.0 +1.0 +1.0 +2.0 diff --git a/maya_node/tests/smoke/smoke_train.py b/maya_node/tests/smoke/smoke_train.py new file mode 100644 index 0000000..519147e --- /dev/null +++ b/maya_node/tests/smoke/smoke_train.py @@ -0,0 +1,198 @@ +""" +Slice 12 mayapy smoke test — rbfmaxTrainAndSave MPxCommand. + +Exercises four scenarios end-to-end: + S1. CSV mode success — train from two .csv files, save to a + fresh JSON, load it via mRBFNode, and + assert predict output bit-identical to + Slice 11's tiny_rbf_expected.json. + S2. Inline mode success — same but via Python list arguments. + S3. --force gate — training into an existing file without + -force must raise. + S4. Kernel string error — "Nonsense" must raise. + +Usage: + smoke_train.py + +where `fixtures_dir` contains tiny_train_centers.csv, +tiny_train_targets.csv, and tiny_rbf_expected.json. + +Exit codes: + 0 — all 4 scenarios passed + 1 — any failure +""" + +from __future__ import print_function + +import json +import os +import sys +import tempfile + + +def _load_interpolator_and_assert(cmds, node_type, plugin_basename, + json_path, expected): + """Helper: load JSON via mRBFNode and compare to expected outputs.""" + node = cmds.createNode(node_type) + try: + cmds.setAttr("{0}.jsonPath".format(node), json_path, + type="string") + for q_entry in expected["queries"]: + q = q_entry["query"] + exp = q_entry["expected"] + cmds.setAttr("{0}.queryPoint".format(node), q, + type="doubleArray") + got = cmds.getAttr("{0}.outputValues".format(node)) + assert got is not None, \ + "outputValues is None (statusMessage={0!r})".format( + cmds.getAttr("{0}.statusMessage".format(node))) + assert len(got) == len(exp), \ + "length {0} != {1}".format(len(got), len(exp)) + for j in range(len(exp)): + err = abs(got[j] - exp[j]) + assert err < 1e-10, \ + "q={0} comp {1}: got {2}, exp {3}, err {4}".format( + q, j, got[j], exp[j], err) + finally: + cmds.delete(node) + cmds.flushUndo() + + +def main(): + if len(sys.argv) != 4: + print("usage: mayapy smoke_train.py " + "", file=sys.stderr) + return 1 + + plugin_path = sys.argv[1] + fixtures_dir = sys.argv[2] + expected_json = sys.argv[3] + + if not os.path.isfile(plugin_path): + print("plugin not found: {0}".format(plugin_path), file=sys.stderr) + return 1 + + centers_csv = os.path.abspath( + os.path.join(fixtures_dir, "tiny_train_centers.csv")) + targets_csv = os.path.abspath( + os.path.join(fixtures_dir, "tiny_train_targets.csv")) + for p in (centers_csv, targets_csv, expected_json): + if not os.path.isfile(p): + print("fixture not found: {0}".format(p), file=sys.stderr) + return 1 + + with open(expected_json, "r") as f: + expected = json.load(f) + + tempdir = tempfile.mkdtemp(prefix="rbfmax_smoke_train_") + csv_out_path = os.path.join(tempdir, "train_csv_out.json") + inline_out_path = os.path.join(tempdir, "train_inline_out.json") + + import maya.standalone + maya.standalone.initialize(name="python") + + try: + import maya.cmds as cmds + + cmds.loadPlugin(plugin_path, quiet=False) + print("[0/4] loadPlugin OK: {0}".format(plugin_path)) + plugin_basename = os.path.splitext(os.path.basename(plugin_path))[0] + + # --- S1: CSV mode ---------------------------------------------- + # Note: Maya's MSyntax::addFlag silently rejects long flag names + # shorter than 4 characters, so -eps/-dim had to be spelled as + # -epsilon and -inputDim. Python keyword args use the same + # spelling. + ret = cmds.rbfmaxTrainAndSave( + centersFile=centers_csv, + targetsFile=targets_csv, + jsonPath=csv_out_path, + kernel="Gaussian", epsilon=1.0, polyDegree=-1, + force=True, + **{"lambda": "1e-6"}) + assert ret == csv_out_path, \ + "expected return value {0!r}, got {1!r}".format( + csv_out_path, ret) + assert os.path.isfile(csv_out_path), \ + "csv-mode output not written: {0}".format(csv_out_path) + _load_interpolator_and_assert( + cmds, "mRBFNode", plugin_basename, csv_out_path, expected) + print("[1/4] S1 csv-mode train + load + predict bit-identical OK") + + # --- S2: inline mode ------------------------------------------- + ret2 = cmds.rbfmaxTrainAndSave( + centers=[0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0], + targets=[0.0, 1.0, 1.0, 2.0], + inputDim=2, outputDim=1, + jsonPath=inline_out_path, + kernel="Gaussian", epsilon=1.0, polyDegree=-1, + force=True, + **{"lambda": "1e-6"}) + assert ret2 == inline_out_path + assert os.path.isfile(inline_out_path) + _load_interpolator_and_assert( + cmds, "mRBFNode", plugin_basename, inline_out_path, expected) + print("[2/4] S2 inline-mode train + load + predict bit-identical OK") + + # --- S3: --force gate ------------------------------------------ + raised = False + try: + cmds.rbfmaxTrainAndSave( + centersFile=centers_csv, + targetsFile=targets_csv, + jsonPath=csv_out_path, # already exists from S1 + kernel="Gaussian", epsilon=1.0, polyDegree=-1, + force=False, + **{"lambda": "1e-6"}) + except RuntimeError as exc: + raised = True + msg = str(exc).lower() + assert "file exists" in msg or "exists" in msg, \ + "S3 error text unexpected: {0!r}".format(exc) + assert raised, "S3 should have raised RuntimeError on existing file" + print("[3/4] S3 force=False on existing file -> RuntimeError OK") + + # --- S4: bogus kernel string ----------------------------------- + raised = False + try: + cmds.rbfmaxTrainAndSave( + centersFile=centers_csv, + targetsFile=targets_csv, + jsonPath=os.path.join(tempdir, "bogus.json"), + kernel="Nonsense", epsilon=1.0, polyDegree=-1, + force=True, + **{"lambda": "1e-6"}) + except RuntimeError as exc: + raised = True + msg = str(exc).lower() + assert ("unknown kernel" in msg) or ("nonsense" in msg), \ + "S4 error text unexpected: {0!r}".format(exc) + assert raised, "S4 should have raised RuntimeError on bad kernel" + print("[4/4] S4 kernel='Nonsense' -> RuntimeError OK") + + cmds.unloadPlugin(plugin_basename) + print("\n=== Slice 12 mayapy train smoke: PASS ===") + return 0 + + except Exception as exc: # noqa: BLE001 + print("[FAIL] {0}".format(exc), file=sys.stderr) + import traceback + traceback.print_exc() + return 1 + + finally: + maya.standalone.uninitialize() + # Clean up tempdir (best-effort). + try: + for fname in os.listdir(tempdir): + try: + os.remove(os.path.join(tempdir, fname)) + except OSError: + pass + os.rmdir(tempdir) + except OSError: + pass + + +if __name__ == "__main__": + sys.exit(main()) From 7e0de3b5345ecc5f2250c9b3a282ebf9ef0530b3 Mon Sep 17 00:00:00 2001 From: 891458249 Date: Tue, 21 Apr 2026 17:32:10 +0800 Subject: [PATCH 3/5] build(cmake): bump VERSION 1.0.0 -> 1.1.0 (Phase 2A complete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2A Slice 12 piece 3 of 5. Advances the project SemVer to 1.1.0 to mark Phase 2A as complete. No tag in this commit — tagging is the human-channel step after the PR merges onto main. Changes: * top-level CMakeLists.txt project(... VERSION 1.1.0 ...) This also updates RBFMAX_VERSION_{MAJOR,MINOR,PATCH} compile definitions and the io_json meta.version string at save time. * maya_node/CMakeLists.txt: RBFMAX_MAYA_PLUGIN_VERSION now plain "1.1.0" (dropped the -phase2a-sliceNN suffix since Phase 2A is finished; future slices will reintroduce a suffix if needed). SemVer accounting (from v1.0.0 baseline): ADDED (MINOR bump): * maya_node/ plugin (mRBFNode + rbfmaxTrainAndSave) * RBFInterpolator::kernel_params() public getter * adapter_core utilities (Slice 11 + 12) BREAKING: none DEPRECATIONS: none Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 2 +- maya_node/CMakeLists.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8ee5b9b..9c2836d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,7 +9,7 @@ cmake_minimum_required(VERSION 3.14) project(rbfmax - VERSION 1.0.0 + VERSION 1.1.0 DESCRIPTION "Industrial-grade RBF interpolation kernel and Maya plug-in" LANGUAGES CXX ) diff --git a/maya_node/CMakeLists.txt b/maya_node/CMakeLists.txt index 5dde97e..728645f 100644 --- a/maya_node/CMakeLists.txt +++ b/maya_node/CMakeLists.txt @@ -19,7 +19,7 @@ if(RBF_BUILD_MAYA_NODE) # into the plugin at compile time for MFnPlugin registration and # future diagnostic use. # ------------------------------------------------------------------ - set(RBFMAX_MAYA_PLUGIN_VERSION "1.0.0-phase2a-slice11") + set(RBFMAX_MAYA_PLUGIN_VERSION "1.1.0") set(RBFMAX_MAYA_PLUGIN_VENDOR "891458249 / RBF_MAX") # R-09 self-check: 0x00013A00 = 80384 (< 0x7FFFF development-range cap). set(RBFMAX_MAYA_NODE_TYPEID "0x00013A00") From 368e50729324d11f110c1b3942a564519cc7e48e Mon Sep 17 00:00:00 2001 From: 891458249 Date: Tue, 21 Apr 2026 17:39:18 +0800 Subject: [PATCH 4/5] docs(readme): Phase 2A complete; CHANGELOG [1.1.0]; maya_node usage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2A Slice 12 piece 4 of 5. User-facing documentation update for the v1.1.0 release. Top-level README.md: * Badge bumped 1.0.0 -> 1.1.0 with the corresponding release-tag link (the tag itself is still pending — the human-channel step after PR merge). * Status section rewritten: "Phase 2A complete (v1.1.0)" with a one-paragraph summary of what the Maya plugin now does (mRBFNode + rbfmaxTrainAndSave, double-validated on Maya 2022 / 2025), and forward-looks to Phase 2B (Viewport 2.0 draw override) + Phase 2C (Qt6 UI). * Features list appended: "Maya 2022/2025 plugin" bullet, points at maya_node/README.md. * Roadmap restructured: Phase 2A ticked, 2B and 2C subdivided, Phase 3/4 unchanged. CHANGELOG.md: * New [1.1.0] entry covering every user-visible Phase 2A delivery: the .mll / .so plugin, mRBFNode attributes, rbfmaxTrainAndSave command flags and modes, kernel_params() getter, FindMaya cmake module, mayapy smoke suite, Maya version matrix status. * "Known limitations" enumerates the real caveats a consumer should see (dev typeId, Maya 2024/2026 deferred, not undoable, MSyntax >= 4 char long names). * Build subsection documents the two opt-in CMake flags. * v1.0.0 entry untouched (append-only). maya_node/README.md: * New "Command: rbfmaxTrainAndSave (Slice 12)" section between Usage and Known limitations. 12-flag table, inline example, CSV example with the fixture contents inline, typical 3-step workflow, exhaustive error-message catalogue. * Files table rewritten to reflect the actual Slice 12 layout: all 17 adapter TESTs, 3 smoke scripts, 4 fixtures (2 JSON + 2 CSV), every source file including the new TUs. * "Known limitations" updated: dynamic array attributes no longer a TODO (Slice 11 delivered them), Maya 2025 validation done (Slice 10C), remaining gap is 2024 / 2026 devkits. No DEVLOG change in this commit — that lands as the next commit along with the Phase 2A retrospective. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 76 +++++++++++++++++++++++ README.md | 40 ++++++++---- maya_node/README.md | 147 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 240 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f50c2..dc1213c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,82 @@ _尚无未发布变更。_ --- +## [1.1.0] — 2026-04-21 + +**Phase 2A complete — Maya node integration + training command.** +First release with a functional Maya plugin. The pure-C++ Phase 1 +kernel (shipped in v1.0.0) is now reachable from inside Maya through +two user-visible primitives: a DG node for prediction and an +MPxCommand for offline training. Validated on Maya 2022 and Maya +2025 with bit-identical output across versions. + +### Added +- `rbfmax_maya.mll` / `.so` — Maya plugin module linking the Phase 1 + `rbfmax::solver` static library. Builds at C++14 against Maya 2022 + ABI, C++17 against Maya 2024 / 2025 / 2026. +- `mRBFNode` DG node (Slice 10A / 11) — loads a trained schema-v1 + JSON from `jsonPath`, serves `predict()` through variable-length + `queryPoint` / `outputValues` attributes, exposes 6 state readouts + (`isLoaded`, `nCenters`, `dimInput`, `dimOutput`, `kernelType`, + `statusMessage`). Typeid `0x00013A00` (development range — see + DEVLOG T-10 before distribution). +- `rbfmaxTrainAndSave` MPxCommand (Slice 12) — wraps Phase 1's + `RBFInterpolator::fit` + `save` in a single MEL / Python call. + Two mutually exclusive input modes: + * **inline** — `centers` / `targets` as Python doubleArray lists + (for small / interactive cases) + * **csv** — `centersFile` / `targetsFile` pointing at comma- + separated files (for large / pipeline cases; '#' line comments + + blank lines tolerated). + Flags: `-jsonPath` (required), `-kernel`, `-epsilon`, `-polyDegree`, + `-lambda` (`"auto"` or numeric), `-force`, plus the mode-specific + flags above. +- `RBFInterpolator::kernel_params() const noexcept` — additive + getter on the Phase 1 interpolator; drives the node's `kernelType` + output attribute without re-parsing the saved JSON. Only Phase 1 + amendment in Phase 2A; `noexcept` / Maya-free / engine-agnostic + contracts preserved. +- `cmake/FindMaya.cmake` + `cmake/MayaVersionMatrix.cmake` — + in-house devkit integration with priority-ordered path resolution + (`-DMAYA_DEVKIT_ROOT` → `$MAYA_DEVKIT_ROOT` → `$MAYA_LOCATION` → + probe paths) and both "bundled" and "separate" devkit layouts. +- mayapy smoke tests: `smoke_hellonode.py` (Slice 10A, legacy + compatibility), `smoke_predict.py` (Slice 11, JSON-path load + + predict bit-identity), `smoke_train.py` (Slice 12, train + load + + predict + error-path coverage). All three pass on Maya 2022 and + Maya 2025 with identical numerical output. +- Adapter test coverage: 3 (H group, Slice 10A) + 6 (C group, + Slice 11) + 8 (D group, Slice 12) = 17 pure-C++ GTest blocks. + Total project tests: **154/154 green** (Phase 1 137 + adapter 17). + +### Unchanged +- Phase 1 API is fully binary-compatible — the lone new symbol is + the additive `kernel_params()` getter. Existing `fit` / `predict` + / `save` / `load` / `clone` / ScratchPool contracts unchanged. +- Phase 1 regression still green: 137/137. + +### Known limitations +- Maya 2024 and Maya 2026 not yet validated locally — devkits + pending on the development machine. Version-matrix evidence from + Slices 10A (2022) and 10C (2025) suggests these will be trivial + when devkits land. +- `mRBFNode` typeId `0x00013A00` is in Autodesk's dev range; any + distribution beyond internal development needs an assigned block. +- `rbfmaxTrainAndSave` is not undoable — the command writes a file + and does not track prior content. Advisory in the usage docs. +- MSyntax long flag names must be ≥ 4 characters (discovered during + Slice 12 smoke debugging as F5 / R-30). Documented in + `maya_node/README.md` and DEVLOG. + +### Build +- Top-level `project(rbfmax VERSION 1.1.0 LANGUAGES CXX)`. +- Two independent options gate the Maya subtree: + `-DRBF_BUILD_MAYA_NODE=ON` (needs devkit), + `-DRBF_BUILD_MAYA_ADAPTER_TESTS=ON` (no devkit). Both default OFF + so Phase 1 builds and existing CI remain untouched. + +--- + ## [1.0.0] — 2026-04-20 **Phase 1 complete.** First major public release. Delivers the diff --git a/README.md b/README.md index beee3f0..33d8563 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,26 @@ Industrial-grade Radial Basis Function interpolation kernel for Autodesk Maya and game engines. [![CI](https://github.com/891458249/RBF_MAX/actions/workflows/ci.yml/badge.svg)](https://github.com/891458249/RBF_MAX/actions/workflows/ci.yml) -[![Version](https://img.shields.io/badge/version-1.0.0-blue)](https://github.com/891458249/RBF_MAX/releases/tag/v1.0.0) +[![Version](https://img.shields.io/badge/version-1.1.0-blue)](https://github.com/891458249/RBF_MAX/releases/tag/v1.1.0) [![License](https://img.shields.io/badge/license-Apache%202.0-green)](LICENSE) [![C++](https://img.shields.io/badge/C%2B%2B-11-orange)](https://en.cppreference.com/w/cpp/11) ## Status -**Phase 1 complete (v1.0.0).** This release delivers the pure C++ -mathematical kernel, solver, spatial index, and I/O layer — the -"math foundation" of the plugin. The kernel is Maya-free and -engine-agnostic: it links against Eigen 3.3.9 and nlohmann/json, -with GoogleTest as the test dependency and Google Benchmark as the -optional performance suite. +**Phase 2A complete (v1.1.0) — Maya node integration + training command.** +Latest release adds the `mRBFNode` Maya DG node (loads trained JSON and +serves `predict()`) and the `rbfmaxTrainAndSave` MPxCommand (offline +training inside Maya, two input modes). Validated on Maya 2022 and +Maya 2025 with bit-identical output across versions. -**Phase 2** (Maya node integration, Viewport 2.0 visualization, -Qt6 UI) is planned as a follow-on project. +**Phase 1 (v1.0.0)** shipped the Maya-free pure C++ mathematical kernel, +solver, spatial index, and I/O layer — still the foundation underneath. +It links against Eigen 3.3.9 and nlohmann/json, with GoogleTest and +Google Benchmark as test / performance dependencies. + +**Phase 2B** (Viewport 2.0 draw override + heatmap visualisation) +and **Phase 2C** (Qt6 UI for pose management) are planned as follow-on +projects. ## Quick Start @@ -98,6 +103,10 @@ mathematical derivations (14 chapters) and - **Strict numerical contract**: double precision internally, explicit NaN propagation, `eigen_assert`-guarded preconditions, `noexcept` throughout. +- **Maya 2022 / 2025 plugin** (since v1.1.0): `mRBFNode` DG node + + `rbfmaxTrainAndSave` MPxCommand; both inline (Python lists) and + CSV training modes; double-validated bit-identical across Maya + versions. See [maya_node/README.md](maya_node/README.md). ## Building @@ -164,10 +173,15 @@ Key decisions: ## Roadmap -- **Phase 2**: Maya node integration (`MPxNode` with `kParallel`, - Viewport 2.0 `MPxDrawOverride` for debug visualization, Qt6 - Model/View UI, Swing-Twist driver, pose manager, JSON-backed - asset pipeline). +- **Phase 2A** ✅ (v1.1.0): Maya plugin (`mRBFNode` + `rbfmaxTrainAndSave` + MPxCommand) with JSON-path load architecture; Maya 2022 + 2025 + validated. +- **Phase 2B** (planned): Viewport 2.0 `MPxDrawOverride` with + heatmap visualisation of RBF influence fields; X-ray ordering + integration. +- **Phase 2C** (planned): Qt6 Pose Manager UI (PySide6 Model/View) + for browsing / editing training samples; train via the + `rbfmaxTrainAndSave` command shipped in 2A. - **Phase 3**: TBB `parallel_for` for batch predict on large character rigs; GPU compute for offline training. - **Phase 4**: Production asset tooling (mirror propagation, diff --git a/maya_node/README.md b/maya_node/README.md index ed4b501..cdeaf47 100644 --- a/maya_node/README.md +++ b/maya_node/README.md @@ -223,6 +223,121 @@ Slice 11 ships no in-Maya training command — that is slated for Slice be edited manually; useful for experimentation but not a production workflow. +## Command: `rbfmaxTrainAndSave` (Slice 12) + +MPxCommand that closes the "train from inside Maya" gap. Reads +training data from Python lists (inline mode) or CSV files, fits +a Phase 1 `RBFInterpolator`, and writes the schema-v1 JSON that +`mRBFNode` can load. + +### Flags + +| Long | Short | Type | Mode | Notes | +|------|-------|------|------|-------| +| `centers` | `c` | doubleArray | inline | Row-major flat list | +| `targets` | `t` | doubleArray | inline | Row-major flat list | +| `inputDim` | `idm` | int | inline | D = centers column count | +| `outputDim` | `od` | int | inline | M = targets column count | +| `centersFile` | `cf` | string | csv | Path to centers.csv (N × D) | +| `targetsFile` | `tf` | string | csv | Path to targets.csv (N × M) | +| `jsonPath` | `jp` | string | **required** | Output JSON path | +| `kernel` | `kn` | string | shared | `Gaussian` (default), `Cubic`, `Linear`, `Quintic`, `ThinPlateSpline`, `InverseMultiquadric` | +| `epsilon` | `ep` | double | shared | Shape parameter (default 1.0). Only used by Gaussian / IMQ | +| `polyDegree` | `pd` | int | shared | -1 = auto via `minimum_polynomial_degree`, 0..3 = explicit | +| `lambda` | `lm` | string | shared | `"auto"` (GCV, default) or numeric like `"1e-6"` | +| `force` | `fo` | bool | shared | Overwrite existing `jsonPath` (default false) | + +> **Maya MSyntax quirk** — long flag names must be **≥ 4 characters** or +> `addFlag` silently drops them. This is why the lambda parameter is +> `-epsilon` (not `-eps`) and the input-dimension is `-inputDim` +> (not `-dim`). Caught as R-30 during Slice 12 smoke debugging. + +### Example — inline mode + +```python +import maya.cmds as cmds +cmds.loadPlugin("rbfmax_maya.mll") + +out = cmds.rbfmaxTrainAndSave( + centers=[0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0], + targets=[0.0, 1.0, 1.0, 2.0], + inputDim=2, outputDim=1, + jsonPath="C:/rigs/my_rbf.json", + kernel="Gaussian", epsilon=1.0, polyDegree=-1, + force=True, + **{"lambda": "1e-6"}) +print(out) # "C:/rigs/my_rbf.json" on success +``` + +### Example — CSV mode + +Contents of `centers.csv` (4 samples, D=2): + +``` +# optional '#' line comments and blank lines are tolerated +0.0,0.0 +1.0,0.0 +0.0,1.0 +1.0,1.0 +``` + +Contents of `targets.csv` (4 samples, M=1): + +``` +0.0 +1.0 +1.0 +2.0 +``` + +```python +cmds.rbfmaxTrainAndSave( + centersFile="C:/data/centers.csv", + targetsFile="C:/data/targets.csv", + jsonPath="C:/rigs/my_rbf.json", + kernel="Gaussian", epsilon=1.0, + force=True, + **{"lambda": "auto"}) +``` + +### Typical workflow + +```python +# 1. Train +cmds.rbfmaxTrainAndSave(centersFile=..., targetsFile=..., + jsonPath="rig.json", force=True) + +# 2. Create node and point it at the freshly-trained file +node = cmds.createNode("mRBFNode") +cmds.setAttr(f"{node}.jsonPath", "rig.json", type="string") + +# 3. Query at runtime +cmds.setAttr(f"{node}.queryPoint", [0.5, 0.5], type="doubleArray") +print(cmds.getAttr(f"{node}.outputValues")) +``` + +### Error behaviour + +Failures raise `RuntimeError` from Python (`MS::kFailure` from +the underlying `MPxCommand`). Typical messages: + +| Trigger | Message pattern | +|---------|-----------------| +| `-jsonPath` missing | `missing required flag -jsonPath / -jp` | +| Inline + CSV flags mixed | `modes are mutually exclusive` | +| Neither mode's flags supplied | `must supply either -centers and -targets ...` | +| Inline without `-inputDim` / `-outputDim` | `inline mode requires both -dim and -outputDim` | +| CSV parse failure | `centers csv parse failed: ` | +| Unknown kernel string | `unknown kernel: ""` | +| `jsonPath` exists and `-force` false | `file exists; pass -force true to overwrite: ...` | +| `lambda` unparseable | `cannot parse -lambda: ""` | +| `solver::fit` failed | `fit failed: ` | +| `RBFInterpolator::save` failed | `save failed (could not write schema-v1 JSON to): ...` | + +The command is **not undoable** — it writes a file rather than +modifying scene state. The `-force` flag is the only guard against +accidental overwrite. + ## Known limitations (Slice 10A) - **Development-range typeId**: `0x00013A00`. This ID is valid for @@ -236,20 +351,32 @@ Slice 11 ships no in-Maya training command — that is slated for Slice SYSTEM-included. Slice 10B+ will reintroduce strict warnings behind `/external:W0` (MSVC ≥ 16.10) or `-isystem` on Clang/GCC. Adapter tests do honour the strict warning set. -- **No dynamic array attributes yet** — Slice 11 adds per-sample centers - and targets as Maya array attributes. -- **No Maya 2024/2025/2026 validation** — see the version table above. +- **Dynamic array attributes** added in Slice 11 (`queryPoint`, + `outputValues` as `MFnDoubleArrayData`) — variable-dim queries + are supported. +- **Maya 2024 / 2026 validation** still pending — devkits not yet + on the development machine. Slice 10C (Maya 2025) and the double- + environment smoke infrastructure are already in place. ## Files | File | Purpose | |------|---------| | `CMakeLists.txt` | Plugin target + adapter test gate | -| `include/rbfmax/maya/adapter_core.hpp` | Pure-C++, C++14-compliant, calls Phase 1 `evaluate_kernel` | -| `include/rbfmax/maya/mrbf_node.hpp` | `MPxNode` skeleton class declaration | +| `include/rbfmax/maya/adapter_core.hpp` | Pure-C++, C++14-compliant helpers (Gaussian eval, attribute marshalling, CSV/lambda parsing) | +| `include/rbfmax/maya/mrbf_node.hpp` | `MPxNode` declaration — JSON-path load + predict | +| `include/rbfmax/maya/rbfmax_train_cmd.hpp` | `MPxCommand` declaration for `rbfmaxTrainAndSave` | | `include/rbfmax/maya/plugin_info.hpp.in` | CMake-configured constants (version, typeId) | -| `src/mrbf_node.cpp` | `compute()` + attribute wiring | -| `src/plugin_main.cpp` | `initializePlugin` / `uninitializePlugin` | -| `tests/CMakeLists.txt` | Adapter test target | -| `tests/test_adapter_core.cpp` | 3 TEST blocks (H1/H2/H3) | -| `tests/smoke/smoke_hellonode.py` | `mayapy` 4-step contract | +| `src/mrbf_node.cpp` | `compute()` + `try_load()` + attribute wiring | +| `src/plugin_main.cpp` | `initializePlugin` / `uninitializePlugin` — registers node + command | +| `src/rbfmax_train_cmd.cpp` | `rbfmaxTrainAndSave` `doIt()` implementation | +| `src/adapter_core_csv.cpp` | Non-inline CSV / lambda parser implementations | +| `tests/CMakeLists.txt` | Adapter test target (also links `adapter_core_csv.cpp`) | +| `tests/test_adapter_core.cpp` | 17 GTest blocks: H1-H3 (Slice 10A) + C1-C6 (Slice 11) + D1-D8 (Slice 12) | +| `tests/smoke/smoke_hellonode.py` | Slice 10A `mayapy` 4-step contract | +| `tests/smoke/smoke_predict.py` | Slice 11 `mayapy` 5-step contract (load + predict bit-identity) | +| `tests/smoke/smoke_train.py` | Slice 12 `mayapy` 4-scenario contract (csv / inline / force / bad-kernel) | +| `tests/smoke/fixtures/tiny_rbf.json` | Slice 11 Phase 1-generated schema-v1 fixture | +| `tests/smoke/fixtures/tiny_rbf_expected.json` | Slice 11 reference predict outputs | +| `tests/smoke/fixtures/tiny_train_centers.csv` | Slice 12 CSV fixture matching tiny_rbf's centers | +| `tests/smoke/fixtures/tiny_train_targets.csv` | Slice 12 CSV fixture matching tiny_rbf's targets | From 24c6c5fad3b5bb053142df72aa17870e3a7acc71 Mon Sep 17 00:00:00 2001 From: 891458249 Date: Tue, 21 Apr 2026 17:42:54 +0800 Subject: [PATCH 5/5] docs(devlog): record Slice 12 + Phase 2A retrospective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2A Slice 12 piece 5 of 5, and the Phase 2A close-out artifact. Slice 12 entry covers: * Scope — close the Slice 11 "must train externally" gap with a new MPxCommand; bump SemVer to 1.1.0. * Full deliverables list across adapter_core extensions, CSV parser TU, command header+impl, plugin registration, CMake wiring, 8 new adapter tests, smoke with 4 scenarios, 2 CSV fixtures, version bump, README / CHANGELOG / maya_node/README updates. * 10 locked design decisions — command vs node, two-mode mutual exclusion, 12 flag matrix, non-undoable trade-off, error-surface contract, multi-use doubleArray idiom, CSV format spec, bit-identity smoke assertion. * F5 deep-dive — MSyntax::addFlag silently rejects long names shorter than 4 chars. Full debugging trail: first drafts using -dim/-eps, short-name rename attempts, diagnostic instrumentation, eventual confirmation via help() listing, resolution to -inputDim/-epsilon. Registered as R-30. * Validation table — Step 1 adapter+Phase 1 154/154, Step 2a/2b Maya 2022/2025 builds 0 warn/err at byte-identical 501 760 B, Step 3a/3b six smokes all exit 0 with bit-identical output across versions, Step 4 Phase 1 regression 137/137. Phase 2A Retrospective: * Timeline: v1.0.0 -> v1.1.0 in one calendar-compressed day. * 4 slices shipped (10A / 10C / 11 / 12); 10B + 10D deferred non-blocking on devkit availability. * Code inventory: maya_node ~1.9k LOC, tests ~760 LOC, 4 fixtures, 2 CMake modules, 1 Phase 1 additive amendment. Plugin binary: 25 KB (10A) -> 502 KB (12). * What went well: grep-verify protocol, JSON-path architecture, double-environment smoke, additive Phase 1 amendment pattern. * What to improve in Phase 2B: flag-name grep discipline, generator-based fixture reproducibility, budgeting for Maya API shakeout, version-matrix acceleration. * Tech debt: R-29 / R-30 living cookbook items; T-10 Autodesk typeId block registration; T-11 closed; T-12 v1.1.0 GitHub Release (closes at human-channel tag push); 10B / 10D version-matrix open but non-blocking. * Entry conditions for Phase 2B documented; context handoff instructions restated (required reading: DEVLOG 10A onward, maya_node/README, CHANGELOG [1.1.0]). Co-Authored-By: Claude Opus 4.6 --- DEVLOG.md | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/DEVLOG.md b/DEVLOG.md index 4538590..4c616de 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -14,6 +14,130 @@ --- +## 2026-04-21 · Slice 12 — rbfmaxTrainAndSave command + v1.1.0 (Phase 2A close-out) + +**Scope**: Phase 2A closing slice. Adds the `rbfmaxTrainAndSave` MPxCommand so users can train from inside Maya, closing the Slice 11 JSON-path architecture's "must train externally" gap. Bumps project SemVer to **1.1.0**; tag is pushed by the human channel after PR merge. + +**Deliverables** +- `maya_node/include/rbfmax/maya/adapter_core.hpp` — 4 new pure-C++ utilities: `unflatten_double_array`, `file_exists`, `parse_csv_matrix` (non-inline), `parse_lambda_arg` (non-inline). All C++14-compliant so both plugin (C++14, Maya 2022 ABI) and adapter tests (C++17) build against the same header. +- `maya_node/src/adapter_core_csv.cpp` — Non-inline implementations of the two parser utilities. Linked into both the plugin target and the adapter test target, Maya-free so it runs on any CI node. +- `maya_node/include/rbfmax/maya/rbfmax_train_cmd.hpp` + `src/rbfmax_train_cmd.cpp` — The command itself. 12 flags across two mutually exclusive modes (inline / CSV). 348 LOC of implementation covering argument parsing, mode detection, data loading, kernel validation, `--force` gating, Phase 1 `fit` + `save`, and detailed error surface. +- `maya_node/src/plugin_main.cpp` — `registerCommand` / `deregisterCommand` wiring + atomic rollback if command registration fails after node registration succeeds. +- `maya_node/CMakeLists.txt` — adds `adapter_core_csv.cpp` and `rbfmax_train_cmd.cpp` to the plugin sources, bumps `RBFMAX_MAYA_PLUGIN_VERSION` to `"1.1.0"`. +- `maya_node/tests/CMakeLists.txt` — links `adapter_core_csv.cpp` into the adapter test target so the D-group tests can reach the non-inline parsers. +- `maya_node/tests/test_adapter_core.cpp` — 8 new D-group TEST blocks (D1–D8) covering `unflatten_double_array`, `parse_csv_matrix`, `parse_lambda_arg`, `file_exists`. Random seed `kSeedS12 = 0xF5BFABu` reserved for future randomised additions. +- `maya_node/tests/smoke/smoke_train.py` — 4-scenario mayapy contract: S1 CSV mode end-to-end, S2 inline mode end-to-end, S3 `-force=False` on existing file raises, S4 bogus kernel string raises. Both success scenarios load the trained JSON via `mRBFNode` and assert predict bit-identity with Slice 11's `tiny_rbf_expected.json`. +- `maya_node/tests/smoke/fixtures/tiny_train_centers.csv`, `tiny_train_targets.csv` — CSV fixtures mirroring the Slice 11 `tiny_rbf.json` sample set so the train-then-predict bit-identity assertion is meaningful. +- `CMakeLists.txt` (top-level) — project VERSION bumped `1.0.0 → 1.1.0`. +- `README.md`, `CHANGELOG.md`, `maya_node/README.md` — user-facing Phase 2A complete announcement, `[1.1.0]` changelog entry, command usage docs. + +**Design decisions (10 locked pre-slice)** +1. **Command-based training, not node-based** — keeps the Slice 11 "jsonPath is the interface" contract intact; training is a one-shot side effect, not a DG operation. +2. **Two mutually exclusive input modes** — inline (for Python REPL / interactive experimentation) and CSV (for pipeline / large N). Mixing the two flag sets is an explicit error. +3. **12 flags with long names ≥ 4 chars** — `inputDim` / `outputDim` / `epsilon` / `polyDegree` / `lambda` / `force` / `kernel` / `jsonPath` / `centers` / `targets` / `centersFile` / `targetsFile`. See F5 below for the ≥4-char rule. +4. **Required flag**: only `-jsonPath`. Every other flag has a default appropriate for Gaussian rig data. +5. **Not undoable** — the command writes a file; undo would need to remember the prior file contents. Out of scope for Slice 12; advised in README. +6. **`-force` gates on file existence**, not on DG state — we don't try to be clever about "is anything downstream reading this file right now?" (that would require tracking `mRBFNode`s pointing at the path, which crosses into Slice 13 territory). +7. **Error surface returns `MS::kFailure` + `displayError`** — Python binding raises `RuntimeError` consistently; smoke tests catch on that. +8. **Multi-use doubleArray** — `makeFlagMultiUse` + `numberOfFlagUses` + `getFlagArgumentList` is the canonical idiom. Each Python list element → one flag use → one double. +9. **CSV format** = comma-delimited, `#` line comments OK, blank lines OK, uniform column count enforced per file. +10. **Bit-identity smoke assertion** — train + save via command, then load + predict via node, must produce the same outputs as Slice 11's reference table (same Phase 1 code path on both sides, so 1e-10 tolerance is defence-in-depth; observed err=0). + +**F5 — MSyntax::addFlag silently rejects long names shorter than 4 characters** + +First drafts used `-dim` and `-eps` per spec. Both registered without error (addFlag returned `kFailure "Unexpected Internal Failure"` but I initially treated that as a transient warning). The Python cmds binding then raised `TypeError: Invalid flag 'eps'` at call time. Diagnostic wrapper around `addFlag` logging failed invocations surfaced the truth: **addFlag returns `kFailure` for any long name under 4 chars**. Probable Maya parser heuristic: 1–3-char long names collide with short-name prefix-match disambiguation logic. + +Workarounds tried and rejected: +- 2–3 char short names (`-di`, `-ep`, `-id`, `-ev`, `-ni`, `-sp`, `-ki`, `-ks`) — all failed because the **long name** was the issue, not the short one. +- First-letter avoidance (`-e` is edit, `-d` is debug) — wasn't the actual cause; Autodesk samples use `-d` / `-e` happily. + +Settled workaround: rename `-dim` → `-inputDim` and `-eps` → `-epsilon` (semantic names, unambiguous, ≥4 chars). Python keyword args use the same spelling. + +Documented as **R-30** in the tech-debt register below. Phase 2 reviewer protocol gets one more entry: **short or long flag names must be grep-verified against Autodesk samples before dispatch**; this was not in the pre-flight check this slice. + +**Tolerance register** +- Adapter D1–D8: `EXPECT_EQ` / `EXPECT_DOUBLE_EQ` throughout. No approximation; these tests are mechanical round-trips. +- Smoke S1 / S2 bit-identity against `tiny_rbf_expected.json`: `1e-10` absolute (inherited from Slice 11). Observed err=0 exactly on both Maya 2022 and 2025 — expected, since the command writes through the same `RBFInterpolator::save` path that the generator used. +- Smoke S3 / S4 error-path assertions: substring match on the Python `RuntimeError` message. No numeric tolerance. + +**Tech-debt register additions** +- **R-30** (new) — MSyntax long flag name ≥ 4-char requirement. Documented in the command source, `maya_node/README.md`, and CHANGELOG known-limitations. No action needed; naming rule is now stable. +- **T-11** (was open) — **closed**. Users can now train from inside Maya via `rbfmaxTrainAndSave`. +- **T-13** (new) — v1.1.0 manual GitHub Release creation (pattern from Slice 09 close-out). Human-channel step after PR merges and tag pushes. + +**Validation outcomes** (Windows 11, MSVC 19.44.35223) + +| Step | Command | Result | +|------|---------|--------| +| 1 | `build-adapter` Release with `RBF_BUILD_MAYA_ADAPTER_TESTS=ON` | **154/154 green**, 12.91 s (137 Phase 1 + 3 H + 6 C + **8 D**) | +| 2a | Maya 2022 `.mll` | 0 warn 0 err, **501 760 bytes** (grew from 158 208 in Slice 11 due to command + CSV parser TUs) | +| 2b | Maya 2025 `.mll` | 0 warn 0 err, **501 760 bytes** (byte-identical to 2022) | +| 3a | Maya 2022: hellonode + predict + train smokes | all **exit 0**; bit-identity on both success scenarios; RuntimeError on both error scenarios | +| 3b | Maya 2025: same 3 smokes | all **exit 0**, bit-identical to 2022 | +| 4 | Phase 1 Release regression | **137/137 green**, 11.93 s | + +**Workflow note** +- Branch `slice-12-train-command` → 5 commits (feat adapter / feat command / build cmake / docs readme / docs devlog) → PR → CI 3 Phase 1 jobs (Maya opts default OFF) → human approve → rebase merge → auto-delete. +- After merge (human channel): `git tag v1.1.0 `, `git push origin v1.1.0`, `gh release create v1.1.0 --title "v1.1.0 — Phase 2A complete"`. + +--- + +## Phase 2A Retrospective + +**Timeline**: 2026-04-21 (from v1.0.0 baseline, after Slice 09 Phase 1 close-out) → 2026-04-21 (v1.1.0). Calendar-compressed single-day Phase. + +**Slices shipped**: **4** (10A, 10C, 11, 12). **10B** (Maya 2024) and **10D** (Maya 2026) deferred — blocked on local devkit availability, non-blocking per Slice 10C evidence that the architecture is version-agnostic. + +**Git releases**: v1.0.0 → **v1.1.0**. + +**Code inventory (approx, at v1.1.0)**: +- `maya_node/` headers + sources: ~1 900 LOC across 9 files (header + 3 sources + 1 .in template + Slice 12 additions) +- `maya_node/tests/`: ~760 LOC (17 GTest blocks + 3 smoke scripts) +- `maya_node/tests/smoke/fixtures/`: 4 fixtures (2 JSON + 2 CSV) +- `cmake/`: +2 Maya-specific modules (FindMaya.cmake, MayaVersionMatrix.cmake) +- Phase 1 amendment: +10 LOC (kernel_params() getter) + 24 LOC test +- Plugin binary size evolution: 25 KB (10A HelloNode) → 158 KB (11 real predict + kernel_params + solver link) → **502 KB** (12 + CSV parser + MPxCommand) + +**What went well** +- **Grep-verify protocol pays compounding interest**. Phase 2 reviewer discipline caught all of G1–G4 (Slice 11) and most of Slice 12's API assumptions before code was written. Only F4 (Slice 11 cmds.setAttr doubleArray list form) and F5 (Slice 12 addFlag ≥4-char) escaped the net — both caught within minutes at execute-time by instrumented logging, neither required spec revision. +- **JSON-path architecture (Slice 11) was the right call**. Slice 12's command is a pure additive feature — no node refactor, no DG dirty-propagation debugging, no migration burden for downstream consumers. Users who only want predict (ship JSON from an external pipeline) pay nothing for users who want train (invoke the command). +- **Double-environment smoke requirement paid off again**. Maya 2022 and 2025 bit-identical across all 6 smokes in Slice 12 — the Slice 10A/10C shift-left investment is now confirmed as "Phase 2 slices get version-matrix parity for free", not just "for the boring skeleton slices". +- **Additive Phase 1 amendment pattern worked**. `kernel_params()` (Slice 11) is the first public API evolution of Phase 1 since v1.0.0, and it ships under "additive const getter + test" precedent with zero behavioural change. Future Phase 2B / 2C will use the same pattern when Phase 1 surface gaps surface. + +**What to improve in Phase 2B** +- **Flag-name grep discipline**. Spec said 12 flags with names like `-dim`, `-eps`. Both were silently rejected by Maya. Phase 2B reviewer protocol: check Autodesk devkit samples for similar flag naming choices before dispatching spec to executor. Goal: move F-number catches back to G-number catches. +- **Generator-based fixture reproducibility**. Slice 11's `generate_tiny_rbf.cpp` pattern worked: fixture data came from the production code path, not hand math. Slice 12 reused the same `tiny_rbf.json` + extended with CSVs generated by consistency (same sample points). Phase 2B Viewport tests will face the same need; carry the pattern forward. +- **Reserved docstring slots for "unknown Maya API oddities"**. Both Slice 11 F4 and Slice 12 F5 were surface-level Maya gotchas that took ~30 minutes of diagnostic scaffolding to track down (adding `MGlobal::displayError` logging, iterating probe scripts). Phase 2B should budget explicit 30-min "first Maya API run" per slice for API shakeout instead of trying to predict. +- **Version matrix acceleration**. 10B (Maya 2024) and 10D (Maya 2026) remain unvalidated. Evidence from 10A/10C says the architecture handles them; the blocker is pure devkit availability. Phase 2B entry condition: either resolve one more devkit OR explicitly down-grade 10B/10D to "opportunistic validation when devkit lands". + +**Tech-debt register carried into Phase 2B** + +| ID | Description | Status | +|---|---|---| +| R-17 | Maya 2022 ABI strict-ness (VS2019 vs VS2022 CRT) | closed by 10A/10C evidence | +| R-18 | mayapy path differences on Linux/macOS | open; not validated locally | +| R-25 | `MFnDoubleArrayData` round-trip cross-version | closed by 11+12 evidence | +| R-26 | Lazy-load I/O in hot compute loop | closed by Slice 11 design | +| R-27 | `unique_ptr` invariants | closed by 11 smokes | +| R-28 | JSON path unicode / backslash | closed; `std::ifstream` handles it | +| R-29 | `cmds.setAttr` doubleArray list form — open living cookbook | carry forward | +| R-30 | `MSyntax::addFlag` long names must be ≥4 chars — open living cookbook | **new in Slice 12** | +| T-10 | Autodesk typeId block registration before public distribution | open; Phase 3 or earlier if we ship to external users | +| T-11 | No training from inside Maya | **closed in Slice 12** | +| T-12 | v1.1.0 manual GitHub Release | **new in Slice 12**; closes at release | +| Slice 10B / 10D | Maya 2024 / 2026 validation | open; non-blocking | + +**Phase 2B Entry Conditions** +- mRBFNode + rbfmaxTrainAndSave baseline stable on Maya 2022/2025 (✅ confirmed here). +- Viewport 2.0 draw override architecture design (`MPxDrawOverride`, `MUIDrawManager`). +- Heatmap visualisation shader / GL state management approach. +- Decision on whether to snapshot one more Maya version (10B / 10D) before Phase 2B starts or defer indefinitely. + +**Context handoff to future collaborators** (copy-pasting the Phase 1 retrospective rule): +- **Required reading** before any Phase 2B code change: this DEVLOG from Slice 10A onward, `maya_node/README.md` (attribute contract + command flags), `CHANGELOG.md` `[1.1.0]` entry (what actually ships to users). Phase 2A decisions are committed, not suggestions. + +--- + ## 2026-04-21 · Slice 11 — mRBFNode real predict via JSON-path load (Phase 2A core) **Scope**: Phase 2A core functional slice. `mRBFNode` graduates from the Slice 10A HelloNode skeleton to a real predictor — it loads a Phase 1 `RBFInterpolator` from a schema-v1 JSON file and serves `predict()` to downstream plugs. First slice where Phase 1's kernel and solver both run inside a Maya plugin. Double-validated on Maya 2022 + Maya 2025 on first try (Slices 10A/10C investment pays off).