11// SPDX-License-Identifier: MIT OR Apache-2.0
22
3- //! RL Critic and Reward Shaping
3+ //! RL critic and reward shaping.
44//!
5- //! This module contains the core logic for translating environmental
6- //! observations into neuromodulatory signals.
5+ //! Translates an [`Environment`] observation into a
6+ //! [`ModulatorVector`] of neuromodulator
7+ //! concentrations. Two critics are provided:
8+ //!
9+ //! - [`SimpleCritic`] — stateless, maps the immediate objective and optional
10+ //! environment signals.
11+ //! - [`TDCritic`] — stateful temporal-difference critic that tracks reward
12+ //! improvement over time.
13+ //!
14+ //! # Quick start
15+ //!
16+ //! ```rust
17+ //! use limbic_critic::{Environment, SimpleCritic, TDCritic, ModulatorVector};
18+ //!
19+ //! /// Minimal stub environment used only for documentation examples.
20+ //! struct StubEnv {
21+ //! objective: f32,
22+ //! surprise: f32,
23+ //! }
24+ //!
25+ //! impl Environment for StubEnv {
26+ //! fn objective(&self) -> f32 {
27+ //! self.objective
28+ //! }
29+ //! fn surprise(&self) -> f32 {
30+ //! self.surprise
31+ //! }
32+ //! }
33+ //!
34+ //! let env = StubEnv {
35+ //! objective: 0.75,
36+ //! surprise: 0.3,
37+ //! };
38+ //!
39+ //! // Stateless mapping: dopamine ∈ [0, 1], ACh from Environment::surprise
40+ //! let simple: ModulatorVector = SimpleCritic::assess(&env);
41+ //! assert!((0.0..=1.0).contains(&simple.dopamine));
42+ //! assert_eq!(simple.acetylcholine, 0.3);
43+ //!
44+ //! // Temporal-difference critic: dopamine ∈ [-1, 1] after tanh of EMA(TD)
45+ //! let mut td = TDCritic::new(0.1);
46+ //! let first = td.assess(&env);
47+ //! assert!((-1.0..=1.0).contains(&first.dopamine));
48+ //! ```
749
850use crate :: environment:: Environment ;
951use crate :: modulators:: ModulatorVector ;
1052
11- /// A stateless critic that calculates neuromodulator levels from the
12- /// environment's immediate signals.
53+ /// A stateless critic that maps immediate environment signals to neuromodulators.
54+ ///
55+ /// `SimpleCritic` stores no history. It therefore cannot compute temporal
56+ /// surprise on its own: acetylcholine is read directly from
57+ /// [`Environment::surprise`] and clamped to `[0.0, 1.0]`. Use [`TDCritic`]
58+ /// when acetylcholine should be derived from the absolute TD error
59+ /// (`abs(td_error).tanh()`).
60+ ///
61+ /// # Mapping
62+ ///
63+ /// | Field | Source | Range |
64+ /// |-------|--------|-------|
65+ /// | `dopamine` | `env.objective()` if positive, else `0.0` | `[0.0, 1.0]` |
66+ /// | `serotonin` | `env.volatility()` | `[0.0, 1.0]` |
67+ /// | `acetylcholine` | `env.surprise()` | `[0.0, 1.0]` |
68+ /// | `norepinephrine` | `env.stress()` | `[0.0, 1.0]` |
69+ ///
70+ /// # Example
1371///
14- /// `SimpleCritic` intentionally does not infer acetylcholine from temporal
15- /// objective deltas because it stores no previous state. Instead,
16- /// acetylcholine is read directly from [`Environment::surprise`] and clamped
17- /// to the valid modulator range. Use [`TDCritic`] when acetylcholine should be
18- /// derived from temporal-difference surprise (`abs(td_error).tanh()`).
72+ /// ```rust
73+ /// use limbic_critic::{Environment, SimpleCritic};
74+ ///
75+ /// struct StubEnv;
76+ /// impl Environment for StubEnv {
77+ /// fn objective(&self) -> f32 { 0.8 }
78+ /// fn surprise(&self) -> f32 { 0.4 }
79+ /// }
80+ ///
81+ /// let mods = SimpleCritic::assess(&StubEnv);
82+ /// assert_eq!(mods.dopamine, 0.8);
83+ /// assert_eq!(mods.acetylcholine, 0.4);
84+ /// ```
1985pub struct SimpleCritic ;
2086
2187impl SimpleCritic {
22- /// Calculates neuromodulator concentrations based on the current
23- /// state of the environment.
88+ /// Calculate neuromodulator concentrations from the current environment.
89+ ///
90+ /// # Dopamine
91+ ///
92+ /// Positive [`Environment::objective`] values are clamped to
93+ /// **`[0.0, 1.0]`**. Negative or zero objectives produce `dopamine = 0.0`
94+ /// (no negative reward signal).
95+ ///
96+ /// # Acetylcholine
97+ ///
98+ /// Taken from [`Environment::surprise`] and clamped to **`[0.0, 1.0]`**.
99+ /// This critic does **not** infer ACh from objective deltas.
100+ ///
101+ /// # Other fields
102+ ///
103+ /// - `serotonin` ← [`Environment::volatility`] clamped to `[0.0, 1.0]`
104+ /// - `norepinephrine` ← [`Environment::stress`] clamped to `[0.0, 1.0]`
24105 pub fn assess ( env : & impl Environment ) -> ModulatorVector {
25106 let objective = env. objective ( ) ;
26107
@@ -44,14 +125,75 @@ impl SimpleCritic {
44125 }
45126}
46127
47- /// A critic that calculates reward based on the Temporal Difference (TD) error.
128+ /// A stateful temporal-difference (TD) critic.
129+ ///
130+ /// Tracks the previous objective and an exponential moving average (EMA) of
131+ /// the TD error so that dopamine reflects *change* in reward rather than
132+ /// absolute level. Acetylcholine is derived from surprise in the TD signal
133+ /// (`abs(td_error).tanh()`), not from [`Environment::surprise`].
134+ ///
135+ /// # Internal state
136+ ///
137+ /// | Field | Meaning |
138+ /// |-------|---------|
139+ /// | `prev_objective` | Objective observed on the previous [`assess`](Self::assess) call; starts at `0.0`. |
140+ /// | `ema_reward` | EMA of successive TD errors (`objective - prev_objective`); starts at `0.0`. |
141+ /// | `alpha` | EMA learning rate in `(0, 1]`. Higher values weight recent TD errors more heavily. |
142+ ///
143+ /// # Mapping
144+ ///
145+ /// | Field | Source | Range |
146+ /// |-------|--------|-------|
147+ /// | `dopamine` | `ema_reward.tanh()` | **`[-1.0, 1.0]`** |
148+ /// | `serotonin` | `env.volatility()` | `[0.0, 1.0]` |
149+ /// | `acetylcholine` | `abs(td_error).tanh()` | `[0.0, 1.0]` |
150+ /// | `norepinephrine` | `env.stress()` | `[0.0, 1.0]` |
151+ ///
152+ /// # Example
153+ ///
154+ /// ```rust
155+ /// use limbic_critic::{Environment, TDCritic};
156+ ///
157+ /// struct StubEnv(f32);
158+ /// impl Environment for StubEnv {
159+ /// fn objective(&self) -> f32 { self.0 }
160+ /// }
161+ ///
162+ /// let mut td = TDCritic::new(0.1);
163+ /// let step1 = td.assess(&StubEnv(0.0));
164+ /// let step2 = td.assess(&StubEnv(1.0));
165+ /// // Improvement produces a higher (more positive) dopamine signal.
166+ /// assert!(step2.dopamine > step1.dopamine);
167+ /// ```
48168pub struct TDCritic {
49169 prev_objective : f32 ,
50170 ema_reward : f32 ,
51171 alpha : f32 , // Learning rate for the EMA
52172}
53173
54174impl TDCritic {
175+ /// Create a new TD critic with the given EMA learning rate.
176+ ///
177+ /// `alpha` controls how quickly the internal EMA of TD errors adapts:
178+ ///
179+ /// - **Small `alpha`** (e.g. `0.05`) — smooth, slow reaction to changes.
180+ /// - **Large `alpha`** (e.g. `0.5`) — fast tracking of recent TD errors.
181+ ///
182+ /// Initial state:
183+ /// - `prev_objective = 0.0`
184+ /// - `ema_reward = 0.0`
185+ ///
186+ /// The first [`assess`](Self::assess) call therefore treats the TD error
187+ /// as `objective - 0.0`.
188+ ///
189+ /// # Example
190+ ///
191+ /// ```rust
192+ /// use limbic_critic::TDCritic;
193+ ///
194+ /// let critic = TDCritic::new(0.2);
195+ /// // critic is ready; call assess(&env) on each time step
196+ /// ```
55197 pub fn new ( alpha : f32 ) -> Self {
56198 Self {
57199 prev_objective : 0.0 ,
@@ -60,7 +202,26 @@ impl TDCritic {
60202 }
61203 }
62204
63- /// Calculates neuromodulator concentrations based on the TD error.
205+ /// Calculate neuromodulator concentrations from the TD error.
206+ ///
207+ /// # Algorithm
208+ ///
209+ /// 1. `td_error = env.objective() - prev_objective`
210+ /// 2. Store the current objective as `prev_objective` for the next call.
211+ /// 3. `acetylcholine = abs(td_error).tanh()`, clamped to `[0.0, 1.0]`.
212+ /// 4. Update EMA: `ema_reward ← (1 - alpha) * ema_reward + alpha * td_error`.
213+ /// 5. `dopamine = ema_reward.tanh()`, clamped to **`[-1.0, 1.0]`**.
214+ /// 6. `serotonin` / `norepinephrine` from `volatility` / `stress`, each
215+ /// clamped to `[0.0, 1.0]`.
216+ ///
217+ /// Unlike [`SimpleCritic::assess`], this method mutates internal state and
218+ /// can produce **negative dopamine** when recent TD errors are negative
219+ /// (worsening outcomes).
220+ ///
221+ /// # Parameters
222+ ///
223+ /// - `env` — environment providing the current objective (and optional
224+ /// stress / volatility signals).
64225 pub fn assess ( & mut self , env : & impl Environment ) -> ModulatorVector {
65226 let objective = env. objective ( ) ;
66227 let td_error = objective - self . prev_objective ;
0 commit comments