memlnaut-nisps/tests/cpp/test_mlp_geo_dislike.cpp

315 lines
12 KiB
C++
Raw Normal View History

feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
// tests/cpp/test_mlp_geo_dislike.cpp — geometric dislike (one-core-engine P3;
// docs/adr/rl-feedback-design.md §2.1/§4/§6.1).
//
// Covers: replay dedup/deepen at radius 0.05, k-NN centroid selection with
// deterministic index tie-break, push direction sign (target moves AWAY from
// the liked centroid), taper, cold-start posMemCount==0 fallback, decay/
// eviction + dislike-multiplier bookkeeping, solo/focus gating, and fixed-seed
// determinism.
#include <array>
#include <cmath>
#include <cstdint>
#include <span>
#include "../../nisps/ml/feedback.hpp"
#include "../../nisps/ml/geo_push.hpp"
#include "../../nisps/ml/mlp.hpp"
#include "../../nisps/ml/replay.hpp"
#include "test_helpers.hpp"
namespace {
using nisps::ml::AvoidStyle;
using nisps::ml::FeedbackAction;
using nisps::ml::FeedbackMode;
using nisps::ml::ReplayView;
using GeoMLP = nisps::ml::MLP<2u, 4u, 4u, 4u, 6u, 8u, 32u>;
using GeoFB = nisps::ml::FeedbackController<GeoMLP, 2u, 16u>;
constexpr std::size_t kNIn = 2u;
constexpr std::size_t kNOut = 6u;
constexpr std::size_t kCap = 16u;
struct RawReplay {
std::array<float, kCap * kNIn> in{};
std::array<float, kCap * kNOut> act{};
std::array<float, kCap> rew{};
std::size_t count = 0u;
ReplayView view() {
return ReplayView(in, act, rew, kNIn, kNOut, kCap, count);
}
};
void set_inputs(GeoMLP& m, float x, float y) {
m.set_input(0u, x);
m.set_input(1u, y);
}
} // namespace
// -- ReplayView primitives ----------------------------------------------------
NISPS_TEST(replay_deepen_within_radius_else_store) {
RawReplay raw;
auto r = raw.view();
const float x1[kNIn] = {0.5f, 0.5f};
const float x2[kNIn] = {0.52f, 0.52f}; // within 0.05 of x1
const float x3[kNIn] = {0.9f, 0.9f}; // far away
const float a[kNOut] = {0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f};
const float a2[kNOut] = {0.9f, 0.8f, 0.7f, 0.6f, 0.5f, 0.4f};
NISPS_EXPECT(!r.deepen_or_store_negative(std::span<const float>(x1), std::span<const float>(a)));
NISPS_ASSERT(r.size() == 1u);
NISPS_EXPECT(r.reward(0) == -1.f);
// Nearby dislike deepens (reward -2) and refreshes the action.
NISPS_EXPECT(r.deepen_or_store_negative(std::span<const float>(x2), std::span<const float>(a2)));
NISPS_ASSERT(r.size() == 1u);
NISPS_EXPECT(r.reward(0) == -2.f);
NISPS_EXPECT(r.action(0)[0] == 0.9f);
// Far dislike stores a new item.
NISPS_EXPECT(!r.deepen_or_store_negative(std::span<const float>(x3), std::span<const float>(a)));
NISPS_ASSERT(r.size() == 2u);
// Deepening clamps at -16.
for (int i = 0; i < 40; ++i) {
r.deepen_or_store_negative(std::span<const float>(x1), std::span<const float>(a));
}
NISPS_EXPECT(r.reward(0) == -16.f);
}
NISPS_TEST(replay_knn_centroid_deterministic_tie_break) {
RawReplay raw;
auto r = raw.view();
const float probe[kNIn] = {0.5f, 0.5f};
// Two positives EQUIDISTANT from the probe, distinct actions; k=1 must
// pick the LOWER index deterministically.
const float pa[kNIn] = {0.4f, 0.5f};
const float pb[kNIn] = {0.6f, 0.5f};
float aa[kNOut]; for (std::size_t j = 0; j < kNOut; ++j) aa[j] = 0.2f;
float ab[kNOut]; for (std::size_t j = 0; j < kNOut; ++j) ab[j] = 0.8f;
r.store(1.f, std::span<const float>(pa), std::span<const float>(aa));
r.store(1.f, std::span<const float>(pb), std::span<const float>(ab));
std::array<float, kNOut> mean{};
const std::size_t used = r.knn_positive_centroid(std::span<const float>(probe), 1u, mean);
NISPS_ASSERT(used == 1u);
NISPS_EXPECT(mean[0] == 0.2f); // index 0 wins the tie
// k=4 with 3 positives → uses all 3; centroid is their mean.
const float pc[kNIn] = {0.5f, 0.6f};
float ac[kNOut]; for (std::size_t j = 0; j < kNOut; ++j) ac[j] = 0.5f;
r.store(1.f, std::span<const float>(pc), std::span<const float>(ac));
const std::size_t used4 = r.knn_positive_centroid(std::span<const float>(probe), 4u, mean);
NISPS_ASSERT(used4 == 3u);
NISPS_EXPECT_NEAR(mean[0], (0.2f + 0.8f + 0.5f) / 3.f, 1e-6);
// Negatives never contribute.
const float nx[kNIn] = {0.5f, 0.5f};
float na[kNOut]; for (std::size_t j = 0; j < kNOut; ++j) na[j] = 0.0f;
r.store(-1.f, std::span<const float>(nx), std::span<const float>(na));
const std::size_t used_after_neg =
r.knn_positive_centroid(std::span<const float>(probe), 4u, mean);
NISPS_EXPECT(used_after_neg == 3u);
}
NISPS_TEST(replay_decay_and_evict) {
RawReplay raw;
auto r = raw.view();
const float x[kNIn] = {0.1f, 0.1f};
const float a[kNOut] = {};
// A shallow negative just above the evict threshold decays out in a few
// calls; rewards move by +0.0025*max(|r|,1) per call.
r.store(-0.012f, std::span<const float>(x), std::span<const float>(a));
NISPS_ASSERT(r.size() == 1u);
std::size_t evicted = r.decay_negatives(); // -0.012 + 0.0025 = -0.0095 > -0.01 → evict
NISPS_EXPECT(evicted == 1u);
NISPS_EXPECT(r.size() == 0u);
// Positives are never decayed/evicted.
r.store(1.f, std::span<const float>(x), std::span<const float>(a));
evicted = r.decay_negatives();
NISPS_EXPECT(evicted == 0u);
NISPS_EXPECT(r.size() == 1u);
}
// -- compute_push_target --------------------------------------------------------
NISPS_TEST(geo_push_target_moves_away_from_centroid) {
nisps::Rng rng(7ull);
std::array<float, kNOut> neg{};
std::array<float, kNOut> mean{};
std::array<float, kNOut> target{};
for (std::size_t j = 0; j < kNOut; ++j) {
neg[j] = 0.6f;
mean[j] = 0.4f; // dir = +0.2 per dim → push increases values
}
fix(ml): re-base the geometric dislike on upstream e291192 — delete the taper geo_push.hpp and replay.hpp cited memllib @ 0a541cc. upstream/main pins e291192, where the same code had been deliberately redesigned — and because InterfaceRL was not in the tree (fixed one commit ago), we carried the superseded version for months. Three changes, all upstream's: kGeometricPushScale 0.5 -> 1.0 (InterfaceRL.hpp:409) kNegLRBase 0.5 -> 1.5 (InterfaceRL.hpp:410) /(1+len) taper deleted (InterfaceRL.tpp:724) Upstream's own comment on the taper: "a 'no' should clearly move the mapping away even from a sound already far from the liked region (the taper used to kill exactly that case)". The direction is already a unit vector, so the taper only ever shrank the push for exactly the sounds a user is most likely to be rejecting. Cold start is folded into the same path. Upstream's useRandom is `!havePositives || len <= 1e-4`: with nothing liked yet there is no centroid to push away from, so every dim goes in a random direction. Ours instead kept the older 0a541cc fallback — train AWAY from the heard action at a NEGATIVE lr — which was inert whenever the heard action equalled the net's own output, i.e. in the common case. One path now, and a "no" moves the mapping before any likes exist (ml_bench E1: 0 -> 2.3e-3). The GeometricColdStart action is still reported so callers keep their "like a few sounds first" prompt; only the training changed. Measured (ml_bench, one dislike at a point): A4 0.0157 -> 0.0533 at-point displacement (3.4x), so end to end across this and the RMSProp fix: 5.3e-5 -> 5.3e-2, ~1000x. The gap to the legacy Diffuse design closes from ~4100x to ~4.2x. D1 effective_lr 4.7e-4 -> 1.5e-3; 10 presses now reach 0.34, 100 reach the full intended push. A5 compounding 0.87 -> 0.96 (a second press at the same spot is no longer noticeably weaker than the first). A7 damage_ratio essentially unchanged (0.87-1.57) — the collateral damage to protected positives scales with the push and is NOT addressed here; it is the negative-feedback design question. NOT adopted, deliberately: upstream's per-tick batch retraining over all live negatives, and its fixed kDislikeLifetimeMs=2500 in place of our proportional decay. Both need something the core does not have — a per-tick call site and a millisecond clock inside nisps/ml — so they change FeedbackControllerCore's interface rather than its constants. Recorded in ALIGNMENT's deferred-debt entry alongside the existing one-press-one-step divergence, and filed as its own task. test_mlp_geo_dislike.cpp: the taper test now pins its ABSENCE (equal displacement near and far), the cold-start test pins movement where it used to pin inertness, and a new test covers the random-direction branch. ALIGNMENT defect 6b resolved. Gates: build-cpp-tests 139 tests / ctest 4/4, parity-check PASS (WASM rebuilt), lint-cpp clean, firmware slpworkshop SUCCESS.
2026-07-25 11:22:15 +02:00
const float step = nisps::ml::geo_push_step(-1.f); // clamp(1,0.25,1)*1.0 = 1.0
NISPS_EXPECT_NEAR(step, 1.f, 1e-7);
nisps::ml::compute_push_target(neg, mean, {}, step, /*have_positives=*/true, rng, target);
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
for (std::size_t j = 0; j < kNOut; ++j) {
NISPS_EXPECT(target[j] > neg[j]); // strictly away from the centroid
NISPS_EXPECT(target[j] <= 1.f);
}
fix(ml): re-base the geometric dislike on upstream e291192 — delete the taper geo_push.hpp and replay.hpp cited memllib @ 0a541cc. upstream/main pins e291192, where the same code had been deliberately redesigned — and because InterfaceRL was not in the tree (fixed one commit ago), we carried the superseded version for months. Three changes, all upstream's: kGeometricPushScale 0.5 -> 1.0 (InterfaceRL.hpp:409) kNegLRBase 0.5 -> 1.5 (InterfaceRL.hpp:410) /(1+len) taper deleted (InterfaceRL.tpp:724) Upstream's own comment on the taper: "a 'no' should clearly move the mapping away even from a sound already far from the liked region (the taper used to kill exactly that case)". The direction is already a unit vector, so the taper only ever shrank the push for exactly the sounds a user is most likely to be rejecting. Cold start is folded into the same path. Upstream's useRandom is `!havePositives || len <= 1e-4`: with nothing liked yet there is no centroid to push away from, so every dim goes in a random direction. Ours instead kept the older 0a541cc fallback — train AWAY from the heard action at a NEGATIVE lr — which was inert whenever the heard action equalled the net's own output, i.e. in the common case. One path now, and a "no" moves the mapping before any likes exist (ml_bench E1: 0 -> 2.3e-3). The GeometricColdStart action is still reported so callers keep their "like a few sounds first" prompt; only the training changed. Measured (ml_bench, one dislike at a point): A4 0.0157 -> 0.0533 at-point displacement (3.4x), so end to end across this and the RMSProp fix: 5.3e-5 -> 5.3e-2, ~1000x. The gap to the legacy Diffuse design closes from ~4100x to ~4.2x. D1 effective_lr 4.7e-4 -> 1.5e-3; 10 presses now reach 0.34, 100 reach the full intended push. A5 compounding 0.87 -> 0.96 (a second press at the same spot is no longer noticeably weaker than the first). A7 damage_ratio essentially unchanged (0.87-1.57) — the collateral damage to protected positives scales with the push and is NOT addressed here; it is the negative-feedback design question. NOT adopted, deliberately: upstream's per-tick batch retraining over all live negatives, and its fixed kDislikeLifetimeMs=2500 in place of our proportional decay. Both need something the core does not have — a per-tick call site and a millisecond clock inside nisps/ml — so they change FeedbackControllerCore's interface rather than its constants. Recorded in ALIGNMENT's deferred-debt entry alongside the existing one-press-one-step divergence, and filed as its own task. test_mlp_geo_dislike.cpp: the taper test now pins its ABSENCE (equal displacement near and far), the cold-start test pins movement where it used to pin inertness, and a new test covers the random-direction branch. ALIGNMENT defect 6b resolved. Gates: build-cpp-tests 139 tests / ctest 4/4, parity-check PASS (WASM rebuilt), lint-cpp clean, firmware slpworkshop SUCCESS.
2026-07-25 11:22:15 +02:00
// NO TAPER (upstream e291192, InterfaceRL.tpp:724): distance from the
// liked centroid must NOT shrink the push. The direction is a unit
// vector either way, so a far-away negative is displaced exactly as far
// as a near one — which is the case the deleted /(1+len) used to kill.
// (Both are clamped at 1.0 here, so compare an unsaturated dim.)
std::array<float, kNOut> near_neg{}, far_mean{}, near_mean{};
std::array<float, kNOut> t_near{}, t_far{};
for (std::size_t j = 0; j < kNOut; ++j) {
near_neg[j] = 0.5f;
near_mean[j] = 0.49f; // len ~= 0.024 across 6 dims
far_mean[j] = 0.0f; // len ~= 1.22
}
nisps::ml::compute_push_target(near_neg, near_mean, {}, 0.1f, true, rng, t_near);
nisps::ml::compute_push_target(near_neg, far_mean, {}, 0.1f, true, rng, t_far);
NISPS_EXPECT_NEAR(t_far[0] - near_neg[0], t_near[0] - near_neg[0], 1e-6);
}
// With nothing liked yet there is no centroid to push away from, so upstream
// pushes in a RANDOM direction per dim rather than doing nothing
// (`useRandom = !havePositives || ...`, InterfaceRL.tpp:745).
NISPS_TEST(geo_push_target_random_direction_when_no_positives) {
nisps::Rng rng(11ull);
std::array<float, kNOut> neg{}, mean{}, target{};
for (std::size_t j = 0; j < kNOut; ++j) { neg[j] = 0.5f; mean[j] = 0.5f; }
nisps::ml::compute_push_target(neg, mean, {}, 0.25f, /*have_positives=*/false,
rng, target);
bool any_moved = false, any_down = false, any_up = false;
for (std::size_t j = 0; j < kNOut; ++j) {
if (target[j] != neg[j]) any_moved = true;
if (target[j] < neg[j]) any_down = true;
if (target[j] > neg[j]) any_up = true;
NISPS_EXPECT(target[j] >= 0.f && target[j] <= 1.f);
}
NISPS_EXPECT(any_moved);
NISPS_EXPECT(any_down && any_up); // per-dim signs, not one global direction
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
}
NISPS_TEST(geo_push_respects_active_mask_and_clamps) {
nisps::Rng rng(7ull);
std::array<float, kNOut> neg{};
std::array<float, kNOut> mean{};
std::array<float, kNOut> target{};
for (std::size_t j = 0; j < kNOut; ++j) {
neg[j] = 0.99f;
mean[j] = 0.01f;
}
std::array<std::uint8_t, kNOut> mask{1u, 0u, 1u, 0u, 1u, 0u};
fix(ml): re-base the geometric dislike on upstream e291192 — delete the taper geo_push.hpp and replay.hpp cited memllib @ 0a541cc. upstream/main pins e291192, where the same code had been deliberately redesigned — and because InterfaceRL was not in the tree (fixed one commit ago), we carried the superseded version for months. Three changes, all upstream's: kGeometricPushScale 0.5 -> 1.0 (InterfaceRL.hpp:409) kNegLRBase 0.5 -> 1.5 (InterfaceRL.hpp:410) /(1+len) taper deleted (InterfaceRL.tpp:724) Upstream's own comment on the taper: "a 'no' should clearly move the mapping away even from a sound already far from the liked region (the taper used to kill exactly that case)". The direction is already a unit vector, so the taper only ever shrank the push for exactly the sounds a user is most likely to be rejecting. Cold start is folded into the same path. Upstream's useRandom is `!havePositives || len <= 1e-4`: with nothing liked yet there is no centroid to push away from, so every dim goes in a random direction. Ours instead kept the older 0a541cc fallback — train AWAY from the heard action at a NEGATIVE lr — which was inert whenever the heard action equalled the net's own output, i.e. in the common case. One path now, and a "no" moves the mapping before any likes exist (ml_bench E1: 0 -> 2.3e-3). The GeometricColdStart action is still reported so callers keep their "like a few sounds first" prompt; only the training changed. Measured (ml_bench, one dislike at a point): A4 0.0157 -> 0.0533 at-point displacement (3.4x), so end to end across this and the RMSProp fix: 5.3e-5 -> 5.3e-2, ~1000x. The gap to the legacy Diffuse design closes from ~4100x to ~4.2x. D1 effective_lr 4.7e-4 -> 1.5e-3; 10 presses now reach 0.34, 100 reach the full intended push. A5 compounding 0.87 -> 0.96 (a second press at the same spot is no longer noticeably weaker than the first). A7 damage_ratio essentially unchanged (0.87-1.57) — the collateral damage to protected positives scales with the push and is NOT addressed here; it is the negative-feedback design question. NOT adopted, deliberately: upstream's per-tick batch retraining over all live negatives, and its fixed kDislikeLifetimeMs=2500 in place of our proportional decay. Both need something the core does not have — a per-tick call site and a millisecond clock inside nisps/ml — so they change FeedbackControllerCore's interface rather than its constants. Recorded in ALIGNMENT's deferred-debt entry alongside the existing one-press-one-step divergence, and filed as its own task. test_mlp_geo_dislike.cpp: the taper test now pins its ABSENCE (equal displacement near and far), the cold-start test pins movement where it used to pin inertness, and a new test covers the random-direction branch. ALIGNMENT defect 6b resolved. Gates: build-cpp-tests 139 tests / ctest 4/4, parity-check PASS (WASM rebuilt), lint-cpp clean, firmware slpworkshop SUCCESS.
2026-07-25 11:22:15 +02:00
nisps::ml::compute_push_target(neg, mean, mask, 0.5f, /*have_positives=*/true,
rng, target);
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
for (std::size_t j = 0; j < kNOut; ++j) {
if (mask[j]) {
NISPS_EXPECT(target[j] >= neg[j]); // pushed (and clamped at 1)
NISPS_EXPECT(target[j] <= 1.f);
} else {
NISPS_EXPECT(target[j] == neg[j]); // frozen dim untouched
}
}
}
// -- controller end-to-end ------------------------------------------------------
NISPS_TEST(geo_dislike_cold_start_then_push) {
GeoMLP m(42ull);
m.draw_weights(0.5f);
GeoFB fb(42ull ^ 0xFEEDBACC0DEull);
NISPS_ASSERT(fb.avoid_style() == AvoidStyle::Geometric); // the P3 default
set_inputs(m, 0.25f, 0.75f);
m.process();
fix(ml): re-base the geometric dislike on upstream e291192 — delete the taper geo_push.hpp and replay.hpp cited memllib @ 0a541cc. upstream/main pins e291192, where the same code had been deliberately redesigned — and because InterfaceRL was not in the tree (fixed one commit ago), we carried the superseded version for months. Three changes, all upstream's: kGeometricPushScale 0.5 -> 1.0 (InterfaceRL.hpp:409) kNegLRBase 0.5 -> 1.5 (InterfaceRL.hpp:410) /(1+len) taper deleted (InterfaceRL.tpp:724) Upstream's own comment on the taper: "a 'no' should clearly move the mapping away even from a sound already far from the liked region (the taper used to kill exactly that case)". The direction is already a unit vector, so the taper only ever shrank the push for exactly the sounds a user is most likely to be rejecting. Cold start is folded into the same path. Upstream's useRandom is `!havePositives || len <= 1e-4`: with nothing liked yet there is no centroid to push away from, so every dim goes in a random direction. Ours instead kept the older 0a541cc fallback — train AWAY from the heard action at a NEGATIVE lr — which was inert whenever the heard action equalled the net's own output, i.e. in the common case. One path now, and a "no" moves the mapping before any likes exist (ml_bench E1: 0 -> 2.3e-3). The GeometricColdStart action is still reported so callers keep their "like a few sounds first" prompt; only the training changed. Measured (ml_bench, one dislike at a point): A4 0.0157 -> 0.0533 at-point displacement (3.4x), so end to end across this and the RMSProp fix: 5.3e-5 -> 5.3e-2, ~1000x. The gap to the legacy Diffuse design closes from ~4100x to ~4.2x. D1 effective_lr 4.7e-4 -> 1.5e-3; 10 presses now reach 0.34, 100 reach the full intended push. A5 compounding 0.87 -> 0.96 (a second press at the same spot is no longer noticeably weaker than the first). A7 damage_ratio essentially unchanged (0.87-1.57) — the collateral damage to protected positives scales with the push and is NOT addressed here; it is the negative-feedback design question. NOT adopted, deliberately: upstream's per-tick batch retraining over all live negatives, and its fixed kDislikeLifetimeMs=2500 in place of our proportional decay. Both need something the core does not have — a per-tick call site and a millisecond clock inside nisps/ml — so they change FeedbackControllerCore's interface rather than its constants. Recorded in ALIGNMENT's deferred-debt entry alongside the existing one-press-one-step divergence, and filed as its own task. test_mlp_geo_dislike.cpp: the taper test now pins its ABSENCE (equal displacement near and far), the cold-start test pins movement where it used to pin inertness, and a new test covers the random-direction branch. ALIGNMENT defect 6b resolved. Gates: build-cpp-tests 139 tests / ctest 4/4, parity-check PASS (WASM rebuilt), lint-cpp clean, firmware slpworkshop SUCCESS.
2026-07-25 11:22:15 +02:00
// Cold start: no positives yet. Since the 2026-07-25 re-base onto
// upstream e291192 this is NOT a separate inert branch — it is the same
// push, in a random direction because there is no centroid to move away
// from. So a 'no' moves the mapping even before anything is liked, which
// is exactly what upstream's comment at InterfaceRL.tpp:724 argues for.
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
auto before = m.get_weights();
std::array<float, GeoMLP::weight_count()> snap{};
for (std::size_t i = 0; i < snap.size(); ++i) snap[i] = before[i];
const FeedbackAction a1 = fb.on_down(m, {}, 0.1f, 0.5f, {});
NISPS_EXPECT(a1 == FeedbackAction::GeometricColdStart);
NISPS_EXPECT(fb.negative_count() == 1u);
fix(ml): re-base the geometric dislike on upstream e291192 — delete the taper geo_push.hpp and replay.hpp cited memllib @ 0a541cc. upstream/main pins e291192, where the same code had been deliberately redesigned — and because InterfaceRL was not in the tree (fixed one commit ago), we carried the superseded version for months. Three changes, all upstream's: kGeometricPushScale 0.5 -> 1.0 (InterfaceRL.hpp:409) kNegLRBase 0.5 -> 1.5 (InterfaceRL.hpp:410) /(1+len) taper deleted (InterfaceRL.tpp:724) Upstream's own comment on the taper: "a 'no' should clearly move the mapping away even from a sound already far from the liked region (the taper used to kill exactly that case)". The direction is already a unit vector, so the taper only ever shrank the push for exactly the sounds a user is most likely to be rejecting. Cold start is folded into the same path. Upstream's useRandom is `!havePositives || len <= 1e-4`: with nothing liked yet there is no centroid to push away from, so every dim goes in a random direction. Ours instead kept the older 0a541cc fallback — train AWAY from the heard action at a NEGATIVE lr — which was inert whenever the heard action equalled the net's own output, i.e. in the common case. One path now, and a "no" moves the mapping before any likes exist (ml_bench E1: 0 -> 2.3e-3). The GeometricColdStart action is still reported so callers keep their "like a few sounds first" prompt; only the training changed. Measured (ml_bench, one dislike at a point): A4 0.0157 -> 0.0533 at-point displacement (3.4x), so end to end across this and the RMSProp fix: 5.3e-5 -> 5.3e-2, ~1000x. The gap to the legacy Diffuse design closes from ~4100x to ~4.2x. D1 effective_lr 4.7e-4 -> 1.5e-3; 10 presses now reach 0.34, 100 reach the full intended push. A5 compounding 0.87 -> 0.96 (a second press at the same spot is no longer noticeably weaker than the first). A7 damage_ratio essentially unchanged (0.87-1.57) — the collateral damage to protected positives scales with the push and is NOT addressed here; it is the negative-feedback design question. NOT adopted, deliberately: upstream's per-tick batch retraining over all live negatives, and its fixed kDislikeLifetimeMs=2500 in place of our proportional decay. Both need something the core does not have — a per-tick call site and a millisecond clock inside nisps/ml — so they change FeedbackControllerCore's interface rather than its constants. Recorded in ALIGNMENT's deferred-debt entry alongside the existing one-press-one-step divergence, and filed as its own task. test_mlp_geo_dislike.cpp: the taper test now pins its ABSENCE (equal displacement near and far), the cold-start test pins movement where it used to pin inertness, and a new test covers the random-direction branch. ALIGNMENT defect 6b resolved. Gates: build-cpp-tests 139 tests / ctest 4/4, parity-check PASS (WASM rebuilt), lint-cpp clean, firmware slpworkshop SUCCESS.
2026-07-25 11:22:15 +02:00
bool moved = false;
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
{
auto after = m.get_weights();
for (std::size_t i = 0; i < snap.size(); ++i) {
fix(ml): re-base the geometric dislike on upstream e291192 — delete the taper geo_push.hpp and replay.hpp cited memllib @ 0a541cc. upstream/main pins e291192, where the same code had been deliberately redesigned — and because InterfaceRL was not in the tree (fixed one commit ago), we carried the superseded version for months. Three changes, all upstream's: kGeometricPushScale 0.5 -> 1.0 (InterfaceRL.hpp:409) kNegLRBase 0.5 -> 1.5 (InterfaceRL.hpp:410) /(1+len) taper deleted (InterfaceRL.tpp:724) Upstream's own comment on the taper: "a 'no' should clearly move the mapping away even from a sound already far from the liked region (the taper used to kill exactly that case)". The direction is already a unit vector, so the taper only ever shrank the push for exactly the sounds a user is most likely to be rejecting. Cold start is folded into the same path. Upstream's useRandom is `!havePositives || len <= 1e-4`: with nothing liked yet there is no centroid to push away from, so every dim goes in a random direction. Ours instead kept the older 0a541cc fallback — train AWAY from the heard action at a NEGATIVE lr — which was inert whenever the heard action equalled the net's own output, i.e. in the common case. One path now, and a "no" moves the mapping before any likes exist (ml_bench E1: 0 -> 2.3e-3). The GeometricColdStart action is still reported so callers keep their "like a few sounds first" prompt; only the training changed. Measured (ml_bench, one dislike at a point): A4 0.0157 -> 0.0533 at-point displacement (3.4x), so end to end across this and the RMSProp fix: 5.3e-5 -> 5.3e-2, ~1000x. The gap to the legacy Diffuse design closes from ~4100x to ~4.2x. D1 effective_lr 4.7e-4 -> 1.5e-3; 10 presses now reach 0.34, 100 reach the full intended push. A5 compounding 0.87 -> 0.96 (a second press at the same spot is no longer noticeably weaker than the first). A7 damage_ratio essentially unchanged (0.87-1.57) — the collateral damage to protected positives scales with the push and is NOT addressed here; it is the negative-feedback design question. NOT adopted, deliberately: upstream's per-tick batch retraining over all live negatives, and its fixed kDislikeLifetimeMs=2500 in place of our proportional decay. Both need something the core does not have — a per-tick call site and a millisecond clock inside nisps/ml — so they change FeedbackControllerCore's interface rather than its constants. Recorded in ALIGNMENT's deferred-debt entry alongside the existing one-press-one-step divergence, and filed as its own task. test_mlp_geo_dislike.cpp: the taper test now pins its ABSENCE (equal displacement near and far), the cold-start test pins movement where it used to pin inertness, and a new test covers the random-direction branch. ALIGNMENT defect 6b resolved. Gates: build-cpp-tests 139 tests / ctest 4/4, parity-check PASS (WASM rebuilt), lint-cpp clean, firmware slpworkshop SUCCESS.
2026-07-25 11:22:15 +02:00
if (after[i] != snap[i]) { moved = true; break; }
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
}
}
fix(ml): re-base the geometric dislike on upstream e291192 — delete the taper geo_push.hpp and replay.hpp cited memllib @ 0a541cc. upstream/main pins e291192, where the same code had been deliberately redesigned — and because InterfaceRL was not in the tree (fixed one commit ago), we carried the superseded version for months. Three changes, all upstream's: kGeometricPushScale 0.5 -> 1.0 (InterfaceRL.hpp:409) kNegLRBase 0.5 -> 1.5 (InterfaceRL.hpp:410) /(1+len) taper deleted (InterfaceRL.tpp:724) Upstream's own comment on the taper: "a 'no' should clearly move the mapping away even from a sound already far from the liked region (the taper used to kill exactly that case)". The direction is already a unit vector, so the taper only ever shrank the push for exactly the sounds a user is most likely to be rejecting. Cold start is folded into the same path. Upstream's useRandom is `!havePositives || len <= 1e-4`: with nothing liked yet there is no centroid to push away from, so every dim goes in a random direction. Ours instead kept the older 0a541cc fallback — train AWAY from the heard action at a NEGATIVE lr — which was inert whenever the heard action equalled the net's own output, i.e. in the common case. One path now, and a "no" moves the mapping before any likes exist (ml_bench E1: 0 -> 2.3e-3). The GeometricColdStart action is still reported so callers keep their "like a few sounds first" prompt; only the training changed. Measured (ml_bench, one dislike at a point): A4 0.0157 -> 0.0533 at-point displacement (3.4x), so end to end across this and the RMSProp fix: 5.3e-5 -> 5.3e-2, ~1000x. The gap to the legacy Diffuse design closes from ~4100x to ~4.2x. D1 effective_lr 4.7e-4 -> 1.5e-3; 10 presses now reach 0.34, 100 reach the full intended push. A5 compounding 0.87 -> 0.96 (a second press at the same spot is no longer noticeably weaker than the first). A7 damage_ratio essentially unchanged (0.87-1.57) — the collateral damage to protected positives scales with the push and is NOT addressed here; it is the negative-feedback design question. NOT adopted, deliberately: upstream's per-tick batch retraining over all live negatives, and its fixed kDislikeLifetimeMs=2500 in place of our proportional decay. Both need something the core does not have — a per-tick call site and a millisecond clock inside nisps/ml — so they change FeedbackControllerCore's interface rather than its constants. Recorded in ALIGNMENT's deferred-debt entry alongside the existing one-press-one-step divergence, and filed as its own task. test_mlp_geo_dislike.cpp: the taper test now pins its ABSENCE (equal displacement near and far), the cold-start test pins movement where it used to pin inertness, and a new test covers the random-direction branch. ALIGNMENT defect 6b resolved. Gates: build-cpp-tests 139 tests / ctest 4/4, parity-check PASS (WASM rebuilt), lint-cpp clean, firmware slpworkshop SUCCESS.
2026-07-25 11:22:15 +02:00
NISPS_EXPECT(moved);
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
fix(ml): re-base the geometric dislike on upstream e291192 — delete the taper geo_push.hpp and replay.hpp cited memllib @ 0a541cc. upstream/main pins e291192, where the same code had been deliberately redesigned — and because InterfaceRL was not in the tree (fixed one commit ago), we carried the superseded version for months. Three changes, all upstream's: kGeometricPushScale 0.5 -> 1.0 (InterfaceRL.hpp:409) kNegLRBase 0.5 -> 1.5 (InterfaceRL.hpp:410) /(1+len) taper deleted (InterfaceRL.tpp:724) Upstream's own comment on the taper: "a 'no' should clearly move the mapping away even from a sound already far from the liked region (the taper used to kill exactly that case)". The direction is already a unit vector, so the taper only ever shrank the push for exactly the sounds a user is most likely to be rejecting. Cold start is folded into the same path. Upstream's useRandom is `!havePositives || len <= 1e-4`: with nothing liked yet there is no centroid to push away from, so every dim goes in a random direction. Ours instead kept the older 0a541cc fallback — train AWAY from the heard action at a NEGATIVE lr — which was inert whenever the heard action equalled the net's own output, i.e. in the common case. One path now, and a "no" moves the mapping before any likes exist (ml_bench E1: 0 -> 2.3e-3). The GeometricColdStart action is still reported so callers keep their "like a few sounds first" prompt; only the training changed. Measured (ml_bench, one dislike at a point): A4 0.0157 -> 0.0533 at-point displacement (3.4x), so end to end across this and the RMSProp fix: 5.3e-5 -> 5.3e-2, ~1000x. The gap to the legacy Diffuse design closes from ~4100x to ~4.2x. D1 effective_lr 4.7e-4 -> 1.5e-3; 10 presses now reach 0.34, 100 reach the full intended push. A5 compounding 0.87 -> 0.96 (a second press at the same spot is no longer noticeably weaker than the first). A7 damage_ratio essentially unchanged (0.87-1.57) — the collateral damage to protected positives scales with the push and is NOT addressed here; it is the negative-feedback design question. NOT adopted, deliberately: upstream's per-tick batch retraining over all live negatives, and its fixed kDislikeLifetimeMs=2500 in place of our proportional decay. Both need something the core does not have — a per-tick call site and a millisecond clock inside nisps/ml — so they change FeedbackControllerCore's interface rather than its constants. Recorded in ALIGNMENT's deferred-debt entry alongside the existing one-press-one-step divergence, and filed as its own task. test_mlp_geo_dislike.cpp: the taper test now pins its ABSENCE (equal displacement near and far), the cold-start test pins movement where it used to pin inertness, and a new test covers the random-direction branch. ALIGNMENT defect 6b resolved. Gates: build-cpp-tests 139 tests / ctest 4/4, parity-check PASS (WASM rebuilt), lint-cpp clean, firmware slpworkshop SUCCESS.
2026-07-25 11:22:15 +02:00
// The same holds when the HEARD action differs from the net's raw output
// (the real browser/firmware case — the user hears the post-pipeline
// vector), which used to be the ONLY case that moved anything.
feat(ml)!: P3 core — geometric dislike in nisps/, jolt/OU + geo ABI Geometric dislike (rl-feedback-design §2.1/§4; upstream InterfaceRL @ 0a541cc ported verbatim, constants included): - nisps/ml/replay.hpp: ReplayView over storage-owned buffers — deepen-or- store negatives (dedup 0.05, clamp -16), k-NN positive centroid with deterministic index tie-break + fixed accumulation order, proportional decay (0.0025*max(|r|,1)) + eviction, order-preserving compaction. - nisps/ml/geo_push.hpp: push-away target (pushStep clamp(|avgNeg|,.25,1) *0.5, taper /(1+len), useRandom on len<=1e-4 via nisps::Rng — the single deliberate divergence from libc rand()), negLRRatio 0.5-0.4*negFraction. - mlp.hpp: train_targets(input, computed-target, lr, out_mask) — trains toward computed targets (negative lr = cold-start train-away); solo/ focus gating zeroes masked derivs. - feedback.hpp: AvoidStyle {Geometric (new default), Diffuse (legacy move_weights, kept for A/B)}; dislike_geometric() collapses upstream's press+optimise into one synchronous call; on_up in geometric Avoid feeds the positive centroid; dislike-multiplier bookkeeping. Storage gains replay buffers (Fixed: ReplayCap=32 firmware default ≈ +8KB SRAM; Dynamic arena: cap 64). - bindings: nisps_ml_feedback_{dislike_geometric,store_positive, positive_count,negative_count,set_avoid_style} + P3.2 jolt/OU ABI: nisps_ml_jolt_{press,step,release,active,lr_scale,tick_lr_ramp}, nisps_ml_explore_{intensity,get_intensity,apply} (OUNoise<4096> over-provisioned; same code the firmware ModeBase runs). - parity v4: Stage 6 scripted geometric session (2 likes → 2 dislikes, f32-exact heard vectors via Math.fround) — 961 floats PASS at 2.4e-7. - tests: test_mlp_geo_dislike.cpp (replay dedup/deepen/clamp, centroid tie-break, push direction/taper/mask/clamp, cold-start inertness + train-away, determinism, Diffuse legacy); legacy Avoid test pinned to Diffuse per the ADR's deliberate-break note. Firmware: PAFSynth .text/.data unchanged (geometric path not referenced by current glue). NOTE: discovered pre-existing bug 10c3e55c — the explore/place wiring is linker-GC'd out of the PAFSynth ELF (predates this refactor; evidence in the ergo task).
2026-07-14 04:16:21 +02:00
std::array<float, kNOut> heard{};
{
auto outs = m.outputs();
for (std::size_t j = 0; j < kNOut; ++j) {
heard[j] = (outs[j] < 0.5f) ? outs[j] + 0.2f : outs[j] - 0.2f;
}
}
const FeedbackAction a1b = fb.on_down(m, heard, 0.1f, 0.5f, {});
NISPS_EXPECT(a1b == FeedbackAction::GeometricColdStart);
// Feed positives via the like path, then dislike → geometric push.
set_inputs(m, 0.2f, 0.2f);
m.process();
NISPS_EXPECT(fb.on_up(m) == FeedbackAction::LikeStore);
set_inputs(m, 0.8f, 0.8f);
m.process();
NISPS_EXPECT(fb.on_up(m) == FeedbackAction::LikeStore);
NISPS_EXPECT(fb.positive_count() == 2u);
set_inputs(m, 0.25f, 0.75f);
m.process();
const FeedbackAction a2 = fb.on_down(m, {}, 0.1f, 0.5f, {});
NISPS_EXPECT(a2 == FeedbackAction::GeometricPush);
NISPS_EXPECT(!fb.exploring());
NISPS_EXPECT(!fb.learning_paused());
}
NISPS_TEST(geo_dislike_deterministic_under_fixed_seed) {
auto run = [](std::span<float> out_weights) {
GeoMLP m(123ull);
m.draw_weights(0.6f);
GeoFB fb(456ull);
set_inputs(m, 0.3f, 0.3f);
m.process();
fb.on_up(m); // positive
set_inputs(m, 0.31f, 0.31f);
m.process();
fb.on_down(m, {}, 0.1f, 0.5f, {}); // geometric push
fb.on_down(m, {}, 0.1f, 0.5f, {}); // deepen + push again
auto w = m.get_weights();
for (std::size_t i = 0; i < w.size(); ++i) out_weights[i] = w[i];
};
std::array<float, GeoMLP::weight_count()> w1{}, w2{};
run(w1);
run(w2);
for (std::size_t i = 0; i < w1.size(); ++i) {
NISPS_ASSERT(w1[i] == w2[i]);
}
}
NISPS_TEST(geo_dislike_diffuse_style_preserves_legacy_path) {
GeoMLP m(9ull);
GeoFB fb(9ull);
fb.set_avoid_style(AvoidStyle::Diffuse);
set_inputs(m, 0.5f, 0.5f);
m.process();
const FeedbackAction a = fb.on_down(m, {}, 0.1f, 0.5f, {});
NISPS_EXPECT(a == FeedbackAction::AvoidPerturb);
NISPS_EXPECT(fb.replay_size() == 0u); // diffuse touches no replay
}