Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions DEVLOG.md

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions kernel/include/rbfmax/feature_spec.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// =============================================================================
// rbfmax/feature_spec.hpp
// -----------------------------------------------------------------------------
// Heterogeneous RBF input specification: scalar + per-block quaternion inputs
// with optional Swing/Twist decomposition per block. Introduced in Phase 2A.5
// Slice 17A as the foundation for production pose-driven solving (Tekken 8
// AnimaDriver parity + chadvernon cmt rbfNode column convention).
//
// Design contract
// ---------------
// * Header-only, zero ABI surface. Types are plain POD-ish aggregates with
// explicit C++11 constructors (no default member initialisers; same
// Slice 02.5.1 convention as FitOptions / KernelParams).
// * FeatureSpec is orthogonal to the legacy scalar-only fit() API. When
// `quat_blocks` is empty, the heterogeneous fit() overload dispatches to
// the legacy path byte-identically (17A-SCALAR-ORACLE hard gate).
// * Column layout (see Slice 17A §C audit derivation, DEVLOG 2026-04-24):
// - Scalar block contributes N columns (one per training sample) when
// scalar_dim > 0; zero otherwise.
// - Each QuatBlock contributes cols_per_pose(space) * N columns:
// Full → 1 column per pose
// Swing | Twist | SwingTwist → 2 columns per pose
// Full mode is a 17A design choice — chadvernon cmt source does not
// cover Full; cmt treats all quaternion inputs as Swing/Twist/
// SwingTwist (2-col). Slices 17B–17I preserve this convention.
//
// Usage
// -----
// using namespace rbfmax;
// FeatureSpec spec(/*scalar_dim=*/3);
// spec.quat_blocks.push_back(QuatBlock(SolverSpace::SwingTwist,
// Vector3::UnitY()));
// Index total_cols = spec.total_distance_columns(N);
// if (spec.is_scalar_only()) { /* legacy fast path */ }
// =============================================================================
#ifndef RBFMAX_FEATURE_SPEC_HPP
#define RBFMAX_FEATURE_SPEC_HPP

#include <cstdint>
#include <vector>

#include "rbfmax/types.hpp"

namespace rbfmax {

// -----------------------------------------------------------------------------
// SolverSpace — per-quat-block rotation decomposition mode
// -----------------------------------------------------------------------------
//
// Full — no decomposition; one column per pose using
// metric::quaternion_geodesic_distance directly.
// Swing — decompose q = swing * twist; use swing distance only (twist
// column is emitted as 0 for layout stability).
// Twist — decompose q = swing * twist; use twist distance only.
// SwingTwist — decompose q = swing * twist; emit both distances.
//
// For Swing / Twist / SwingTwist, the QuatBlock::axis field specifies the
// twist axis passed to rotation::decompose_swing_twist. For Full, axis is
// ignored at fit time but retained on the struct for schema round-trip.
//
enum class SolverSpace : std::int32_t {
Full = 0,
Swing = 1,
Twist = 2,
SwingTwist = 3,
};

// -----------------------------------------------------------------------------
// QuatBlock — single quaternion input block configuration
// -----------------------------------------------------------------------------

struct QuatBlock {
SolverSpace space;
Vector3 axis; ///< Twist axis (unit length for Swing/Twist/SwingTwist;
///< ignored for Full). Default-constructed to (0,0,0).

QuatBlock() noexcept
: space(SolverSpace::Full), axis(Vector3::Zero()) {}

QuatBlock(SolverSpace s, const Vector3& ax) noexcept
: space(s), axis(ax) {}
};

// -----------------------------------------------------------------------------
// FeatureSpec — full heterogeneous input description
// -----------------------------------------------------------------------------
//
// Invariant: scalar_dim >= 0 and quat_blocks may be empty. The scalar-only
// predicate `is_scalar_only()` returns true iff quat_blocks is empty, and
// this is the condition that triggers legacy fit() delegation in Slice 17A.
//
// `scalar_dim == 0 && quat_blocks.empty()` is a degenerate spec; validation
// in solver::fit rejects it via FitStatus::INVALID_INPUT.
//
struct FeatureSpec {
Index scalar_dim; ///< number of scalar input axes
std::vector<QuatBlock> quat_blocks; ///< empty => scalar-only

FeatureSpec() noexcept
: scalar_dim(0), quat_blocks() {}

explicit FeatureSpec(Index d) noexcept
: scalar_dim(d), quat_blocks() {}

FeatureSpec(Index d, const std::vector<QuatBlock>& blocks)
: scalar_dim(d), quat_blocks(blocks) {}

/// True iff no quat_blocks are configured. The heterogeneous solver
/// dispatches to the legacy scalar-only fit() code path when this is
/// true, guaranteeing byte-identical output via 17A-SCALAR-ORACLE.
bool is_scalar_only() const noexcept {
return quat_blocks.empty();
}

/// Columns contributed by a single QuatBlock per training pose under
/// a given SolverSpace. Full → 1; Swing | Twist | SwingTwist → 2.
/// Encodes the Drift #3 fix from the 17A pre-dispatch audit.
static Index cols_per_pose(SolverSpace s) noexcept {
return (s == SolverSpace::Full) ? Index(1) : Index(2);
}

/// Total number of distance-matrix columns assembled at fit time for
/// N training samples under this spec.
///
/// total = (scalar_dim > 0 ? N : 0)
/// + Σ_k cols_per_pose(quat_blocks[k].space) * N
Index total_distance_columns(Index N) const noexcept {
Index cols = (scalar_dim > 0) ? N : Index(0);
for (std::size_t i = 0; i < quat_blocks.size(); ++i) {
cols += cols_per_pose(quat_blocks[i].space) * N;
}
return cols;
}
};

} // namespace rbfmax

#endif // RBFMAX_FEATURE_SPEC_HPP
78 changes: 76 additions & 2 deletions kernel/include/rbfmax/solver.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
// info() flags. The chosen path is reported in FitResult::solver_path,
// and condition_number is filled only on the BDCSVD branch.
// * Polynomial tail (poly_degree ≥ 0) is solved via QR elimination — see
// docs/math_derivation.md §13.
// docs/spec/math_derivation.md §13.
// * Lambda lower bound: kLambdaMin (1e-12). Smaller values are silently
// clamped in Release; Debug builds trigger eigen_assert.
// * All public functions are noexcept. Errors surface via the
Expand All @@ -41,9 +41,11 @@
#define RBFMAX_SOLVER_HPP

#include <cstdint>
#include <vector>

#include <Eigen/Core>

#include "rbfmax/feature_spec.hpp"
#include "rbfmax/kernel_functions.hpp"
#include "rbfmax/types.hpp"

Expand Down Expand Up @@ -121,6 +123,29 @@ struct FitResult {
Scalar condition_number; ///< -1 unless BDCSVD path was taken.
Scalar residual_norm; ///< ||A w + P v - y||_F / ||y||_F.

// -------------------------------------------------------------------------
// Phase 2A.5 Slice 17A additions (ABI-additive tail fields).
// All default-constructed to empty/zero when the legacy scalar-only
// fit(centers, targets, opts, lambda) path is taken — this is the
// 17A-SCALAR-ORACLE invariant, verified by the test group of the same
// name in tests/test_feature_spec.cpp.
//
// Populated only by the heterogeneous fit() overloads when
// spec.quat_blocks is non-empty (see feature_spec.hpp).
//
// sample_radii is intentionally NOT added here — it arrives in Slice 17F
// with the proper name (no slice suffix), per Phase 2A.5 plan Decision 4.
// -------------------------------------------------------------------------
FeatureSpec feature_spec; ///< owned copy of fit's spec;
///< default (scalar_dim=0, no blocks)
///< in scalar-only fits.
std::vector<MatrixX> quat_features; ///< N × 4 per block (x,y,z,w);
///< empty for scalar-only fits.
VectorX feature_norms; ///< cmt step-1 per-scalar-column L2;
///< empty for scalar-only fits.
Scalar distance_norm; ///< cmt step-3 Frobenius of scalar
///< distance block; 0 for scalar-only.

FitResult() noexcept
: weights(),
poly_coeffs(),
Expand All @@ -131,7 +156,11 @@ struct FitResult {
solver_path(SolverPath::FAILED),
status(FitStatus::INVALID_INPUT),
condition_number(-1),
residual_norm(0) {}
residual_norm(0),
feature_spec(),
quat_features(),
feature_norms(),
distance_norm(0) {}
};

// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -198,6 +227,51 @@ FitResult fit(const Eigen::Ref<const MatrixX>& centers,
const FitOptions& options,
LambdaAuto) noexcept;

// -----------------------------------------------------------------------------
// Heterogeneous fit (Phase 2A.5 Slice 17A) — scalar + quaternion input blocks
// -----------------------------------------------------------------------------
//
// Adds optional quaternion input blocks alongside the classical scalar
// feature matrix. Each row of `scalar_centers` corresponds to the same
// row in every `quat_features[k]` and in `targets` — a single training
// pose.
//
// Layout of `quat_features`:
// * size() must equal spec.quat_blocks.size()
// * each MatrixX has shape N × 4 with column order (x, y, z, w)
// * each row must be unit-length within kQuatIdentityEps (violation
// surfaces as FitStatus::INVALID_INPUT)
//
// Dispatch behavior (legacy compatibility — 17A-SCALAR-ORACLE invariant):
// * If `spec.is_scalar_only()` (i.e. quat_blocks is empty), this
// function explicitly calls the legacy
// fit(scalar_centers, targets, options, lambda) overload and returns
// its output verbatim. The legacy function body is NOT refactored
// into a shared core; byte-identical output with all pre-17A
// diagnostic fields (status, solver_path, condition_number,
// residual_norm) preserved. The tail fields (feature_spec,
// quat_features, feature_norms, distance_norm) remain
// default-constructed in the scalar-only path.
// * Otherwise, the cmt-style composite distance-matrix pipeline
// (linearRegressionSolver.cpp setFeatures steps 1-6) builds the
// heterogeneous matrix. Slice 17A keeps rbfmax's classical
// Tikhonov solver fallback (LLT → LDLT → BDCSVD); the one-hot θ
// switch is deferred to Slice 17E.
//
FitResult fit(const Eigen::Ref<const MatrixX>& scalar_centers,
const std::vector<MatrixX>& quat_features,
const Eigen::Ref<const MatrixX>& targets,
const FitOptions& options,
const FeatureSpec& spec,
Scalar lambda) noexcept;

FitResult fit(const Eigen::Ref<const MatrixX>& scalar_centers,
const std::vector<MatrixX>& quat_features,
const Eigen::Ref<const MatrixX>& targets,
const FitOptions& options,
const FeatureSpec& spec,
LambdaAuto) noexcept;

/// Predict a single output channel (column 0 of weights/poly_coeffs).
Scalar predict_scalar(const FitResult& fr,
const Eigen::Ref<const VectorX>& x) noexcept;
Expand Down
Loading
Loading