// nisps/ml/replay.hpp — reward-tagged replay memory algorithms for the // geometric-dislike feedback mode (docs/adr/rl-feedback-design.md §4). // // Ported from upstream InterfaceRL (memllib @ 0a541cc "highlighting"): // * `_perform_dislike_action()` (InterfaceRL.cpp:42-66) — nearby-negative // deepening within Euclidean 0.05, else store reward=-1. // * `optimise()` k-NN positive centroid (InterfaceRL.cpp:602-627). // * proportional negative decay + eviction (InterfaceRL.cpp:664, :752-760). // // STORAGE: the buffers live in the feedback controller's storage policy // (nisps/ml/feedback.hpp — fixed std::array on firmware, arena slice in the // browser). `ReplayView` binds those spans plus the live item count and // carries the algorithms — no ownership, no heap, deterministic. // // DETERMINISM (the classic float-sum parity traps, per the ADR): // * k-NN selection uses a fixed-size insertion into k slots with ties // broken by LOWER index (no std::sort, no heap). // * centroid accumulation runs in slot order (nearest first) — a fixed // summation order so native == WASM bitwise. // * eviction compacts in place preserving insertion order. #pragma once #include #include #include #include "../core/perf.hpp" namespace nisps::ml { // Upstream constants (InterfaceRL.hpp:293-296, .cpp:42-66,664). inline constexpr float kReplayDedupRadius = 0.05f; inline constexpr float kReplayDecayStep = 0.0025f; inline constexpr float kReplayEvictThreshold = -0.01f; inline constexpr float kMaxDislikeMagnitude = 16.f; inline constexpr std::size_t kCentroidK = 4u; // A non-owning view over the replay buffers (inputs: cap×n_in, actions: // cap×n_out, rewards: cap) plus the live count. All methods deterministic, // allocation-free. class ReplayView { public: ReplayView(std::span inputs, std::span actions, std::span rewards, std::size_t n_in, std::size_t n_out, std::size_t cap, std::size_t& count) noexcept : inputs_(inputs), actions_(actions), rewards_(rewards), n_in_(n_in), n_out_(n_out), cap_(cap), count_(count) {} std::size_t size() const noexcept { return count_; } std::size_t capacity() const noexcept { return cap_; } std::span input(std::size_t i) const noexcept { return inputs_.subspan(i * n_in_, n_in_); } std::span action(std::size_t i) const noexcept { return actions_.subspan(i * n_out_, n_out_); } float reward(std::size_t i) const noexcept { return rewards_[i]; } std::size_t positive_count() const noexcept { std::size_t n = 0u; for (std::size_t i = 0; i < count_; ++i) { if (rewards_[i] > 0.f) ++n; } return n; } std::size_t negative_count() const noexcept { std::size_t n = 0u; for (std::size_t i = 0; i < count_; ++i) { if (rewards_[i] <= 0.f) ++n; } return n; } // Mean reward across negatives (≤ 0); 0 when there are none. Fixed // accumulation order (insertion order). float avg_negative_reward() const noexcept { float sum = 0.f; std::size_t n = 0u; for (std::size_t i = 0; i < count_; ++i) { if (rewards_[i] <= 0.f) { sum += rewards_[i]; ++n; } } return (n > 0u) ? (sum / static_cast(n)) : 0.f; } // Store an item. When full, the OLDEST item is evicted (shift-down — // deterministic, preserves relative order). void store(float reward, std::span x, std::span a) noexcept { std::size_t slot; if (count_ < cap_) { slot = count_++; } else { evict_(0u); slot = count_++; } write_(slot, reward, x, a); } // Upstream `_perform_dislike_action` core: a negative within Euclidean // `radius` of x has its reward deepened (clamped at -kMaxDislikeMagnitude) // and its ACTION REFRESHED to the latest heard vector; otherwise a new // reward=-1 item is stored. Returns true when an existing item deepened. bool deepen_or_store_negative(std::span x, std::span a, float radius = kReplayDedupRadius) noexcept { for (std::size_t i = 0; i < count_; ++i) { if (rewards_[i] < 0.f && distance_(i, x) < radius) { float r = rewards_[i] - 1.f; if (r < -kMaxDislikeMagnitude) r = -kMaxDislikeMagnitude; rewards_[i] = r; auto act = actions_.subspan(i * n_out_, n_out_); const std::size_t n = (a.size() < n_out_) ? a.size() : n_out_; for (std::size_t j = 0; j < n; ++j) act[j] = a[j]; return true; } } store(-1.f, x, a); return false; } // k-NN positive centroid (InterfaceRL.cpp:602-627): mean action of the k // positives nearest to x. Writes into `mean` (n_out floats) and returns // the number of positives used (0 ⇒ cold start; `mean` untouched). // Deterministic: fixed k-slot insertion, ties keep the LOWER index; // accumulation in slot order. std::size_t knn_positive_centroid(std::span x, std::size_t k, std::span mean) const noexcept { constexpr std::size_t kMaxK = 8u; if (k > kMaxK) k = kMaxK; float best_d[kMaxK]; std::size_t best_i[kMaxK]; std::size_t used = 0u; for (std::size_t i = 0; i < count_; ++i) { if (rewards_[i] <= 0.f) continue; const float d = distance_(i, x); // Insertion: strictly-less displaces, so equal distances keep the // earlier (lower-index) item. std::size_t pos = used; while (pos > 0u && d < best_d[pos - 1u]) --pos; if (pos >= k) continue; const std::size_t tail = (used < k) ? used : (k - 1u); for (std::size_t m = tail; m > pos; --m) { best_d[m] = best_d[m - 1u]; best_i[m] = best_i[m - 1u]; } best_d[pos] = d; best_i[pos] = i; if (used < k) ++used; } if (used == 0u) return 0u; for (std::size_t j = 0; j < n_out_; ++j) mean[j] = 0.f; for (std::size_t s = 0; s < used; ++s) { const auto act = action(best_i[s]); for (std::size_t j = 0; j < n_out_; ++j) mean[j] += act[j]; } const float inv = 1.f / static_cast(used); for (std::size_t j = 0; j < n_out_; ++j) mean[j] *= inv; return used; } // Proportional decay of every negative (`reward += 0.0025 * max(|r|, 1)`) // and in-place eviction of items decayed past -0.01. Returns the number // evicted (the caller halves its dislike multiplier per expiry, matching // upstream InterfaceRL.cpp:752-760). std::size_t decay_negatives() noexcept { std::size_t evicted = 0u; std::size_t i = 0u; while (i < count_) { if (rewards_[i] <= 0.f) { const float mag = (rewards_[i] < 0.f) ? -rewards_[i] : rewards_[i]; rewards_[i] += kReplayDecayStep * ((mag > 1.f) ? mag : 1.f); if (rewards_[i] > kReplayEvictThreshold) { evict_(i); ++evicted; continue; // same index now holds the next item } } ++i; } return evicted; } void clear() noexcept { count_ = 0u; } private: float distance_(std::size_t i, std::span x) const noexcept { const auto in = input(i); const std::size_t n = (x.size() < n_in_) ? x.size() : n_in_; float acc = 0.f; for (std::size_t j = 0; j < n; ++j) { const float d = in[j] - x[j]; acc += d * d; } return std::sqrt(acc); } void write_(std::size_t slot, float reward, std::span x, std::span a) noexcept { auto in = inputs_.subspan(slot * n_in_, n_in_); auto act = actions_.subspan(slot * n_out_, n_out_); const std::size_t nx = (x.size() < n_in_) ? x.size() : n_in_; const std::size_t na = (a.size() < n_out_) ? a.size() : n_out_; for (std::size_t j = 0; j < n_in_; ++j) in[j] = (j < nx) ? x[j] : 0.f; for (std::size_t j = 0; j < n_out_; ++j) act[j] = (j < na) ? a[j] : 0.f; rewards_[slot] = reward; } // Remove item i, shifting everything after it down one slot. void evict_(std::size_t i) noexcept { for (std::size_t m = i + 1u; m < count_; ++m) { auto dst_in = inputs_.subspan((m - 1u) * n_in_, n_in_); auto src_in = inputs_.subspan(m * n_in_, n_in_); for (std::size_t j = 0; j < n_in_; ++j) dst_in[j] = src_in[j]; auto dst_act = actions_.subspan((m - 1u) * n_out_, n_out_); auto src_act = actions_.subspan(m * n_out_, n_out_); for (std::size_t j = 0; j < n_out_; ++j) dst_act[j] = src_act[j]; rewards_[m - 1u] = rewards_[m]; } --count_; } std::span inputs_; std::span actions_; std::span rewards_; std::size_t n_in_; std::size_t n_out_; std::size_t cap_; std::size_t& count_; }; } // namespace nisps::ml