flybrain/services/flysim/crates/flybrain-core/src/decoder.rs
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

732 lines
32 KiB
Rust

//! Population-rate readout, bit-exact with `readout/decoder.ts`.
//!
//! A decoder turns per-role population rates into a set of active output channels. Nothing about
//! the downstream device enters this module; presets (see [`gameboy`] and [`platformer`]) supply
//! the channel names, roles and timings for a concrete device and game.
pub mod gameboy;
pub mod platformer;
use std::collections::HashMap;
use crate::error::{bail, Result};
use crate::jsmath::{js_max, js_min};
use crate::ordered::NumberMap;
/// At most one channel of the group is active at a time.
#[derive(Debug, Clone, PartialEq)]
pub struct ExclusiveGroup {
/// Channel name -> rate role. Channel order breaks argmax ties.
pub channels: Vec<(String, String)>,
/// Milliseconds between winner re-decisions.
pub decision_ms: f64,
/// Milliseconds the winner stays active.
pub hold_ms: f64,
/// A challenger must lead the current winner by this factor to take over (1.15 = 15%).
pub hysteresis: f64,
/// Fatigue added to the winner at each decision, capped at 1.
pub fatigue_gain: f64,
/// Fatigue of every other channel is multiplied by this at each decision.
pub fatigue_decay: f64,
/// Fatigue forced onto a channel the environment reports blocked, or 0 for the rule off.
///
/// The blocked-direction cooldown (`docs/readout.md`): a direction that produced no movement
/// is habituated at once rather than over the several decisions `fatigue_gain` would take, so
/// a wall costs one hold instead of a minute. Nothing about the environment enters the score:
/// the caller says *which* channel is blocked and this says how hard that is penalised.
pub blocked_fatigue: f64,
/// Milliseconds the environment must observe no movement before it calls the held channel
/// blocked, or 0 for the rule off.
///
/// The decoder never reads this. The caller owns the clock and the position -- in `flysim` the
/// sim loop, from the adapter's sampled coordinates -- and this is where the number lives so
/// that a preset stays one object and `docs/readout.md` has one table to state it in.
pub blocked_ms: f64,
}
/// The alternate threshold and cooldown used while `decode(.., boot = true)`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BootVariant {
pub cooldown_ms: f64,
pub threshold: f64,
}
/// An independent, threshold-triggered pulse channel.
#[derive(Debug, Clone, PartialEq)]
pub struct PulseChannel {
pub channel: String,
/// Rate role driving this channel.
pub role: String,
/// Milliseconds the pulse stays active.
pub hold_ms: f64,
/// Milliseconds before the channel may fire again.
pub cooldown_ms: f64,
/// The normalized score must exceed this for the channel to fire.
pub threshold: f64,
pub boot: Option<BootVariant>,
/// Channels sharing a throttle group share their cooldown.
pub throttle_group: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DecoderConfig {
pub exclusive: Option<ExclusiveGroup>,
/// A second exclusive group, the macro channels (`docs/design/macros.md` sections 11 and 12).
///
/// Same rules as [`DecoderConfig::exclusive`] and, in the Game Boy preset, the same numbers:
/// a macro is a button, pressed by its own population, decided by hold, hysteresis and
/// fatigue like a direction. Two differences, both of them the caller's:
///
/// - only the channels the caller names as *bound* compete at a decision
/// ([`PopulationDecoder::decode_bound`]); the scene decides which buttons exist, so an
/// unbound macro is masked out of the argmax rather than beaten in it;
/// - nothing reports a macro channel blocked, so `blocked_fatigue` is carried for symmetry
/// and is untouched in practice.
///
/// `None` -- every preset written before section 11, and the platformer -- leaves the decoder
/// exactly as it was: no channels, no state, no decision.
pub macros: Option<ExclusiveGroup>,
pub pulses: Vec<PulseChannel>,
/// Cooldown applied to every channel by `clear_holds`.
pub clear_lockout_ms: f64,
}
/// Serialized decoder state (version 4).
#[derive(Debug, Clone, PartialEq)]
pub struct DecoderState {
pub version: u32,
pub calibrated: bool,
/// Rate role -> calibration rate.
pub baseline: NumberMap,
/// Channel -> millisecond timestamp the channel stays active until.
pub held_until: NumberMap,
/// Channel -> millisecond timestamp the channel may next fire.
pub next_allowed: NumberMap,
/// Millisecond timestamp of the next exclusive-group decision.
pub next_decision: f64,
/// Current exclusive-group winner, or `None`.
pub current: Option<String>,
/// Exclusive channel -> fatigue in [0, 1].
pub fatigue: NumberMap,
/// Millisecond timestamp of the next macro-group decision.
///
/// The macro group's three fields are additive and the schema version does not move with
/// them: a checkpoint written before the group existed carries none of them, and the group
/// then starts rested with no winner -- the same rule the network's rates follow ("missing
/// rate roles import as 0"). That is what keeps a live checkpoint loadable across
/// `docs/design/macros.md` section 11, and a build without the group ignores the fields.
pub macro_next_decision: f64,
/// Current macro-group winner, or `None`.
pub macro_current: Option<String>,
/// Macro channel -> fatigue in [0, 1].
pub macro_fatigue: NumberMap,
}
/// The checkpoint schema version this decoder writes and accepts.
pub const DECODER_STATE_VERSION: u32 = 4;
#[derive(Debug, Clone)]
pub struct PopulationDecoder {
config: DecoderConfig,
/// Exclusive channels in config order.
exclusive_channels: Vec<String>,
/// Macro-group channels in config order.
macro_channels: Vec<String>,
/// Every channel: exclusive channels first (config order), then pulses, then the macro group.
///
/// The macro channels go last so that every index, every map order and every `active` list a
/// consumer already reads is untouched by their arrival.
channels: Vec<String>,
roles: HashMap<String, String>,
throttle_groups: HashMap<String, Vec<String>>,
baseline: NumberMap,
/// Roles a restored checkpoint carried no baseline for, calibrated from the rates at the first
/// [`PopulationDecoder::decode`] after the restore.
///
/// `docs/readout.md`, "Channels added after a checkpoint was written". A checkpoint predates
/// any channel group added since it was written — the macro group is the case this was found
/// on — and [`PopulationDecoder::import_state`] assigns the baselines it has, which leaves the
/// new roles absent. An absent baseline reads as zero, so the score
/// `(rate + 1) / (baseline + 1)` becomes `rate + 1`: the channel competes on its **raw rate**
/// against channels normalized to about 1, and a group whose raw rates span 27 to 145 Hz is
/// decided by which population happens to fire fastest. Live on 2026-09-17, forty-eight
/// minutes in Oak's lab: `macro_talk` at 41 Hz could not beat `macro_frontier` at 54 or
/// `macro_objective` at 65, so `TALK` was never chosen and the rung that needed one A press
/// was never earned.
///
/// Calibrating them from the first decode's rates is what warm-up does
/// (`NeuralAgent::warmup`: settle, then `calibrate` on one snapshot of the settled rates), so
/// this is the same rule at the same fidelity — one sample, not a window — applied to the
/// channels warm-up never saw. Transient: consumed by the next decode and not part of
/// [`DecoderState`], so no checkpoint schema moves.
pending_baseline: Vec<String>,
held_until: NumberMap,
next_allowed: NumberMap,
/// The normalized score of every channel as of the last `decode`, before fatigue.
///
/// Written once per `decode`, read by nobody in this module: see
/// [`PopulationDecoder::last_scores`]. Not in [`DecoderState`], because it is derived from the
/// rates a decode is given rather than state a decode carries, so the checkpoint schema and
/// the compatibility string are untouched.
last_scores: NumberMap,
calibrated: bool,
next_decision: f64,
current: Option<String>,
fatigue: NumberMap,
macro_next_decision: f64,
macro_current: Option<String>,
macro_fatigue: NumberMap,
}
impl PopulationDecoder {
pub fn new(config: DecoderConfig) -> Result<Self> {
let exclusive_channels: Vec<String> = config
.exclusive
.as_ref()
.map(|group| {
group
.channels
.iter()
.map(|(channel, _)| channel.clone())
.collect()
})
.unwrap_or_default();
let macro_channels: Vec<String> = config
.macros
.as_ref()
.map(|group| {
group
.channels
.iter()
.map(|(channel, _)| channel.clone())
.collect()
})
.unwrap_or_default();
let mut channels = exclusive_channels.clone();
channels.extend(config.pulses.iter().map(|pulse| pulse.channel.clone()));
channels.extend(macro_channels.iter().cloned());
let unique: std::collections::HashSet<&String> = channels.iter().collect();
if unique.len() != channels.len() {
bail!("Duplicate decoder channel");
}
let mut roles = HashMap::new();
if let Some(group) = &config.exclusive {
for (channel, role) in &group.channels {
roles.insert(channel.clone(), role.clone());
}
}
for pulse in &config.pulses {
roles.insert(pulse.channel.clone(), pulse.role.clone());
}
if let Some(group) = &config.macros {
for (channel, role) in &group.channels {
roles.insert(channel.clone(), role.clone());
}
}
let held_until = NumberMap::from_pairs(channels.iter().map(|channel| (channel, 0.0)));
let next_allowed = NumberMap::from_pairs(channels.iter().map(|channel| (channel, 0.0)));
// 1.0 is "at rest": a decoder that has never decoded reports every channel at its
// calibration rate rather than at zero, which would read as "far below baseline".
let last_scores = NumberMap::from_pairs(channels.iter().map(|channel| (channel, 1.0)));
let fatigue =
NumberMap::from_pairs(exclusive_channels.iter().map(|channel| (channel, 0.0)));
let macro_fatigue =
NumberMap::from_pairs(macro_channels.iter().map(|channel| (channel, 0.0)));
let mut throttle_groups: HashMap<String, Vec<String>> = HashMap::new();
for pulse in &config.pulses {
if let Some(group) = &pulse.throttle_group {
throttle_groups
.entry(group.clone())
.or_default()
.push(pulse.channel.clone());
}
}
Ok(Self {
config,
exclusive_channels,
macro_channels,
channels,
roles,
throttle_groups,
baseline: NumberMap::new(),
pending_baseline: Vec::new(),
held_until,
next_allowed,
last_scores,
calibrated: false,
next_decision: 0.0,
current: None,
fatigue,
macro_next_decision: 0.0,
macro_current: None,
macro_fatigue,
})
}
/// Channel names this decoder can report, in decode order.
pub fn channel_names(&self) -> &[String] {
&self.channels
}
pub fn calibrated(&self) -> bool {
self.calibrated
}
/// How long the caller must see no movement before it reports a channel blocked, or 0 for the
/// rule off. The decoder never uses it itself: see [`ExclusiveGroup::blocked_ms`].
pub fn blocked_ms(&self) -> f64 {
self.config.exclusive.as_ref().map_or(0.0, |group| group.blocked_ms)
}
/// The exclusive group's current winner, or `None` before the first decision.
///
/// Read-only. `flysim`'s sim loop needs it to name the channel it is about to report blocked,
/// which is the one it is holding.
pub fn current(&self) -> Option<&str> {
self.current.as_deref()
}
/// The macro group's channels in config order, or an empty slice with no group.
pub fn macro_channel_names(&self) -> &[String] {
&self.macro_channels
}
/// The macro group's current winner, or `None` before its first decision.
///
/// This is the macro the game layer starts (`docs/design/macros.md` section 12: "whichever
/// bound channel wins, starts"). It is also `active`'s own answer while the winner is held,
/// which is what the layer actually reads; this accessor is for a log line and a test.
pub fn macro_current(&self) -> Option<&str> {
self.macro_current.as_deref()
}
/// The normalized score of every channel as the last [`PopulationDecoder::decode`] computed
/// it: `(rate[role] + 1) / (baseline[role] + 1)`, one entry per exclusive channel and one per
/// pulse channel, in `channel_names` order.
///
/// Read-only, and deliberately *before* the exclusive group's fatigue division: this is the
/// score `docs/readout.md` defines, where 1.0 is the calibration rate and above 1.0 is above
/// it, not the habituated number one decision happens to argmax over. Nothing in this module
/// reads it back, so it cannot enter a decision: the winner is still the argmax of the same
/// scores over the same rates, and `flybrain-core/tests/decoder.rs` pins that a run that
/// reads it decodes identically to one that does not.
///
/// Who wants it: the game layer in palette and plan modes
/// (`docs/design/macros.md` section 10, "the game layer reads its per-channel normalized
/// scores"). A decoder that has not decoded yet — uncalibrated, or freshly constructed —
/// reports every channel at 1.0, which is "at rest".
pub fn last_scores(&self) -> &NumberMap {
&self.last_scores
}
/// Record the resting rates every score is normalized against.
pub fn calibrate(&mut self, rates: &NumberMap) {
for channel in &self.channels {
let role = &self.roles[channel];
self.baseline.set(role, rates.get_or_zero(role));
}
self.pending_baseline.clear();
self.calibrated = true;
}
/// Roles still waiting for a baseline after a restore, in channel order. For `/status`.
pub fn pending_baseline_roles(&self) -> &[String] {
&self.pending_baseline
}
/// The rate role each channel reads, in channel order. For `/status`.
pub fn channel_roles(&self) -> Vec<(&str, &str)> {
self.channels
.iter()
.map(|channel| (channel.as_str(), self.roles[channel].as_str()))
.collect()
}
/// The resting rate each role is normalized against, as [`PopulationDecoder::calibrate`]
/// recorded it. For `/status`.
pub fn baselines(&self) -> &NumberMap {
&self.baseline
}
/// Drop every hold, forget the exclusive winner and lock all channels out for
/// `clear_lockout_ms`.
pub fn clear_holds(&mut self, now_ms: f64) {
for channel in &self.channels {
self.held_until.set(channel, 0.0);
self.next_allowed
.set(channel, now_ms + self.config.clear_lockout_ms);
}
self.current = None;
let names: Vec<String> = self.fatigue.keys().cloned().collect();
for channel in names {
self.fatigue.set(&channel, 0.0);
}
self.next_decision = now_ms;
self.macro_current = None;
let names: Vec<String> = self.macro_fatigue.keys().cloned().collect();
for channel in names {
self.macro_fatigue.set(&channel, 0.0);
}
self.macro_next_decision = now_ms;
}
/// Advance the readout to `now_ms` and return the active channel names: exclusive channels
/// first (config order), then pulses (config order).
pub fn decode(&mut self, rates: &NumberMap, now_ms: f64, boot: bool) -> Vec<String> {
self.decode_blocked(rates, now_ms, boot, None)
}
/// [`PopulationDecoder::decode`] plus the blocked-direction cooldown.
///
/// `blocked` names an exclusive channel the environment has observed producing no movement.
/// Its only effect is to raise that channel's fatigue to [`ExclusiveGroup::blocked_fatigue`]
/// straight away, so the next decision sees a habituated incumbent rather than an untired one.
/// `max` rather than `+=` because the caller reports the same blockage on every frame of a
/// hold: one wall is one penalty however many times it is observed.
///
/// This is the only input to the readout that is not a rate, and it is deliberately the
/// narrowest one that fixes a wall bump: it says "that button did nothing", not where the fly
/// is, where it should go, or what the room looks like. `blocked_fatigue = 0` -- every preset
/// written before the rule existed -- ignores it entirely, and so does a `blocked` naming a
/// channel outside the group. It needs no new checkpoint field, because fatigue is already in
/// [`DecoderState`].
pub fn decode_blocked(
&mut self,
rates: &NumberMap,
now_ms: f64,
boot: bool,
blocked: Option<&str>,
) -> Vec<String> {
self.decode_bound(rates, now_ms, boot, blocked, None)
}
/// [`PopulationDecoder::decode_blocked`] plus the macro group's bound-channel mask.
///
/// `bound` is the set of macro channels that may win this decision: the scene's own buttons
/// (`docs/design/macros.md` section 12, "the scene decides which buttons exist ... unbound
/// channels are masked from the decision"). `None` -- and every caller that has no macro
/// group -- lets every channel of the group compete, which is what the direction group always
/// does; `Some(&[])` is a scene with no macro at all and takes no decision.
///
/// Masking rather than penalising, and that is the one place where it differs from the
/// blocked-direction cooldown it is modelled on: a blocked channel is habituated and can
/// still win, while an unbound channel is not on the pad and must not be pressable at any
/// score. Its fatigue relaxes meanwhile, exactly as a channel that lost a decision does, so a
/// button coming back onto the pad is neither penalised nor freshened for having been off it.
/// A masked channel cannot stay `current` either: the winner is always one of `bound`.
pub fn decode_bound(
&mut self,
rates: &NumberMap,
now_ms: f64,
boot: bool,
blocked: Option<&str>,
bound: Option<&[String]>,
) -> Vec<String> {
if !self.calibrated {
return Vec::new();
}
let blocked_fatigue =
self.config.exclusive.as_ref().map_or(0.0, |group| group.blocked_fatigue);
if let Some(channel) = blocked.filter(|channel| {
blocked_fatigue > 0.0 && self.fatigue.get(channel).is_some()
}) {
// `js_max`/`js_min`, not the inherent ones: `Math.max` propagates NaN and Rust's
// `f64::max` swallows it, and this module is bit-exact with `readout/decoder.ts`.
let raised =
js_min(1.0, js_max(blocked_fatigue, self.fatigue.get_or_zero(channel)));
self.fatigue.set(channel, raised);
}
// The same rule for the macro group, for the caller that one day reports a macro that
// moved nothing. Nothing in the tree does today, so this is symmetry rather than a live
// path, and it is here rather than absent because the group's contract says "the same
// hold, hysteresis, fatigue and blocked rules as the direction group".
let macro_blocked_fatigue =
self.config.macros.as_ref().map_or(0.0, |group| group.blocked_fatigue);
if let Some(channel) = blocked.filter(|channel| {
macro_blocked_fatigue > 0.0 && self.macro_fatigue.get(channel).is_some()
}) {
let raised = js_min(
1.0,
js_max(macro_blocked_fatigue, self.macro_fatigue.get_or_zero(channel)),
);
self.macro_fatigue.set(channel, raised);
}
let mut scores = NumberMap::new();
// A role the restored checkpoint had no baseline for is calibrated here, from this decode's
// rates, before any score is computed: see `pending_baseline`. Every such channel therefore
// scores exactly 1.0 on this decision -- at rest, which is what a channel nobody has
// measured honestly is -- instead of competing on its raw rate.
if !self.pending_baseline.is_empty() {
for role in std::mem::take(&mut self.pending_baseline) {
self.baseline.set(&role, rates.get_or_zero(&role));
}
}
for channel in &self.channels {
let role = &self.roles[channel];
scores.set(
channel,
(rates.get_or_zero(role) + 1.0) / (self.baseline.get_or_zero(role) + 1.0),
);
}
// The one write of the read-only accessor, here rather than at the end of the decode so
// that it is the score of `docs/readout.md` and not the fatigue-divided copy the exclusive
// group's argmax works on. `assign` over the same key set, so the map keeps its order and
// allocates nothing.
self.last_scores.assign(&scores);
if let Some(group) = self.config.exclusive.clone() {
decide_exclusive(
&group,
&self.exclusive_channels,
None,
&mut scores,
&mut self.fatigue,
&mut self.current,
&mut self.next_decision,
&mut self.held_until,
now_ms,
);
}
// The macro group, after the buttons and on its own clock, with only the scene's bound
// channels competing (`docs/design/macros.md` section 12).
if let Some(group) = self.config.macros.clone() {
decide_exclusive(
&group,
&self.macro_channels,
bound,
&mut scores,
&mut self.macro_fatigue,
&mut self.macro_current,
&mut self.macro_next_decision,
&mut self.held_until,
now_ms,
);
}
for pulse in &self.config.pulses {
let variant = match (boot, pulse.boot) {
(true, Some(boot)) => boot,
_ => BootVariant {
cooldown_ms: pulse.cooldown_ms,
threshold: pulse.threshold,
},
};
if scores.get_or_zero(&pulse.channel) > variant.threshold
&& now_ms >= self.next_allowed.get_or_zero(&pulse.channel)
{
self.held_until.set(&pulse.channel, now_ms + pulse.hold_ms);
let next_allowed = now_ms + variant.cooldown_ms;
self.next_allowed.set(&pulse.channel, next_allowed);
if let Some(name) = &pulse.throttle_group {
if let Some(group) = self.throttle_groups.get(name) {
for channel in group.clone() {
self.next_allowed.set(&channel, next_allowed);
}
}
}
}
}
self.channels
.iter()
.filter(|channel| now_ms < self.held_until.get_or_zero(channel))
.cloned()
.collect()
}
pub fn export_state(&self) -> DecoderState {
DecoderState {
version: DECODER_STATE_VERSION,
calibrated: self.calibrated,
baseline: self.baseline.clone(),
held_until: self.held_until.clone(),
next_allowed: self.next_allowed.clone(),
next_decision: self.next_decision,
current: self.current.clone(),
fatigue: self.fatigue.clone(),
macro_next_decision: self.macro_next_decision,
macro_current: self.macro_current.clone(),
macro_fatigue: self.macro_fatigue.clone(),
}
}
/// Load a version 4 checkpoint. Every field is validated before anything is written, so a
/// rejected checkpoint leaves the decoder untouched.
///
/// The prototype `MotorDecoder` checkpoints (versions `undefined`, 2 and 3) that
/// `readout/decoder.ts` also accepts are deliberately out of scope for this port: nothing on
/// the Rust side has ever written one.
pub fn import_state(&mut self, state: &DecoderState) -> Result<()> {
if state.version != DECODER_STATE_VERSION {
bail!("Invalid decoder version");
}
let records = [&state.baseline, &state.held_until, &state.next_allowed];
if !state.next_decision.is_finite()
|| records
.iter()
.any(|record| record.values().any(|value| !value.is_finite()))
// The macro channels are exempt from "every channel must be in the checkpoint":
// a checkpoint written before the group existed carries none of them and they start
// at zero, which is the whole reason `docs/design/macros.md` section 11 could add
// them without moving the schema version. A macro entry that *is* present must still
// be finite, which the `records` sweep above covers.
|| self.required_channels().any(|channel| {
!state
.held_until
.get(channel)
.is_some_and(|value| value.is_finite())
|| !state
.next_allowed
.get(channel)
.is_some_and(|value| value.is_finite())
})
{
bail!("Invalid decoder checkpoint");
}
if self.fatigue.keys().any(|channel| {
!state
.fatigue
.get(channel)
.is_some_and(|value| value.is_finite() && (0.0..=1.0).contains(&value))
}) {
bail!("Invalid decoder fatigue");
}
// The macro group's fatigue is optional for the same reason, so an absent entry is not a
// fault and a present one is held to the same bounds.
if self.macro_fatigue.keys().any(|channel| {
state
.macro_fatigue
.get(channel)
.is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
}) || !state.macro_next_decision.is_finite()
{
bail!("Invalid decoder fatigue");
}
if let Some(current) = &state.current {
if !self.exclusive_channels.contains(current) {
bail!("Invalid decoder channel");
}
}
if let Some(current) = &state.macro_current {
if !self.macro_channels.contains(current) {
bail!("Invalid decoder channel");
}
}
// Which roles this checkpoint does not know about, in channel order and deduplicated: the
// first decode after the restore calibrates them. Read off the record that came *in*
// rather than off `self.baseline`, so a decoder reused across two restores cannot keep a
// baseline the new checkpoint never had.
self.pending_baseline.clear();
for channel in &self.channels {
let role = &self.roles[channel];
if !state.baseline.contains(role) && !self.pending_baseline.iter().any(|held| held == role)
{
self.pending_baseline.push(role.clone());
}
}
self.baseline.assign(&state.baseline);
self.held_until.assign(&state.held_until);
self.next_allowed.assign(&state.next_allowed);
self.calibrated = state.calibrated;
self.next_decision = state.next_decision;
self.fatigue = state.fatigue.clone();
self.current = state.current.clone();
// The macro group, channel by channel rather than by wholesale assignment, so that a
// checkpoint carrying none of them leaves every macro channel rested at zero.
self.macro_next_decision = state.macro_next_decision;
self.macro_current = state.macro_current.clone();
for channel in self.macro_channels.clone() {
let value = state.macro_fatigue.get(&channel).unwrap_or(0.0);
self.macro_fatigue.set(&channel, value);
}
Ok(())
}
/// Channels a version 4 checkpoint must carry: the direction group and the pulses.
fn required_channels(&self) -> impl Iterator<Item = &String> {
self.channels
.iter()
.filter(|channel| !self.macro_channels.contains(channel))
}
}
/// One exclusive group's decision, shared by the direction group and the macro group.
///
/// Lifted verbatim out of [`PopulationDecoder::decode_bound`] when the macro group arrived, so
/// the two groups cannot drift: the same fatigue division, the same tie rule, the same hysteresis,
/// the same hold. `competing` is the only thing that differs between them -- `None` for the
/// direction group, where every channel always competes, and the bound set for the macro group --
/// and with `None` this is statement for statement the decode that shipped in v0.1.x.
///
/// A free function, and every piece of state it touches is a parameter, because the alternative is
/// a method that borrows `self` mutably while reading `self.config`.
#[allow(clippy::too_many_arguments)]
fn decide_exclusive(
group: &ExclusiveGroup,
channels: &[String],
competing: Option<&[String]>,
scores: &mut NumberMap,
fatigue: &mut NumberMap,
current: &mut Option<String>,
next_decision: &mut f64,
held_until: &mut NumberMap,
now_ms: f64,
) {
if channels.is_empty() || now_ms < *next_decision {
return;
}
// Bounded habituation stops a small persistent rate bias from holding one channel forever.
for channel in channels {
let score = scores.get_or_zero(channel);
scores.set(channel, score / (1.0 + fatigue.get_or_zero(channel)));
}
// The field of candidates: every channel of the group, or only the ones the caller named.
let candidates: Vec<&String> = match competing {
None => channels.iter().collect(),
Some(bound) => channels
.iter()
.filter(|channel| bound.iter().any(|name| name == *channel))
.collect(),
};
// A scene that binds nothing takes no decision at all: no winner, no hold, no fatigue, and
// the clock does not advance, so the frame a button appears on is a frame that can press it.
let Some((first, rest)) = candidates.split_first() else {
return;
};
// Ties keep the earlier channel: only a strictly greater score displaces the running best.
let mut best = (*first).clone();
for channel in rest {
if scores.get_or_zero(channel) > scores.get_or_zero(&best) {
best = (*channel).clone();
}
}
if let Some(held) = current.as_ref() {
// The incumbent's commitment bonus, but only while it is still on the pad: a channel the
// scene has taken away cannot hold its own seat.
if candidates.contains(&held)
&& scores.get_or_zero(&best) < scores.get_or_zero(held) * group.hysteresis
{
best = held.clone();
}
}
for channel in channels {
held_until.set(channel, 0.0);
}
*current = Some(best.clone());
for channel in channels {
let value = fatigue.get_or_zero(channel);
let next = if *channel == best {
js_min(1.0, value + group.fatigue_gain)
} else {
value * group.fatigue_decay
};
fatigue.set(channel, next);
}
held_until.set(&best, now_ms + group.hold_ms);
*next_decision = now_ms + group.decision_ms;
}