Skip to content

Commit 103e85c

Browse files
authored
docs: expand public API rustdoc and gate CI on cargo doc (#36)
Document ModulatorVector field semantics, SimpleCritic/TDCritic assess mappings and ranges (dopamine [0,1] vs [-1,1]), TDCritic state/alpha, and Environment trait methods. Add module-level usage examples with stub environments. Fail CI when rustdoc warnings appear via RUSTDOCFLAGS=-D warnings. Closes #22 Closes #23
1 parent f4f0098 commit 103e85c

4 files changed

Lines changed: 342 additions & 46 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ jobs:
4343
- name: Clippy (lint)
4444
run: cargo clippy --all-targets --all-features -- -D warnings
4545

46+
- name: Build documentation
47+
run: cargo doc --no-deps --all-features
48+
env:
49+
RUSTDOCFLAGS: "-D warnings"
50+
4651
- name: Build
4752
run: cargo build --all-features
4853

src/critic.rs

Lines changed: 175 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,107 @@
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
850
use crate::environment::Environment;
951
use 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+
/// ```
1985
pub struct SimpleCritic;
2086

2187
impl 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+
/// ```
48168
pub struct TDCritic {
49169
prev_objective: f32,
50170
ema_reward: f32,
51171
alpha: f32, // Learning rate for the EMA
52172
}
53173

54174
impl 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;

src/environment.rs

Lines changed: 99 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,123 @@
11
// SPDX-License-Identifier: MIT OR Apache-2.0
22

3-
//! Environment Trait
3+
//! Environment interface for critic evaluation.
44
//!
5-
//! Defines the interface for any external system that the `limbic-critic`
6-
//! needs to evaluate. This trait abstracts the source of the objective
7-
//! function, allowing the critic to be agnostic to whether it's evaluating
8-
//! a trading bot, a game AI, or a hardware system.
5+
//! The [`Environment`] trait abstracts the external system that a critic
6+
//! observes — a trading bot, game AI, hardware controller, LLM training loop,
7+
//! or any other process that can expose a scalar objective (and optional
8+
//! risk / stress / surprise signals).
9+
//!
10+
//! Critics such as [`SimpleCritic`](crate::SimpleCritic) and
11+
//! [`TDCritic`](crate::TDCritic) depend only on this trait, remaining
12+
//! agnostic to domain-specific details.
13+
//!
14+
//! # Implementing `Environment`
15+
//!
16+
//! Only [`objective`](Environment::objective) is required. The other methods
17+
//! default to `0.0` and can be overridden when the corresponding modulator
18+
//! channel is useful:
19+
//!
20+
//! | Method | Default | Used by critics as |
21+
//! |--------|---------|--------------------|
22+
//! | [`objective`](Environment::objective) | *(required)* | reward / TD target |
23+
//! | [`volatility`](Environment::volatility) | `0.0` | serotonin |
24+
//! | [`surprise`](Environment::surprise) | `0.0` | acetylcholine (`SimpleCritic` only) |
25+
//! | [`stress`](Environment::stress) | `0.0` | norepinephrine |
26+
//!
27+
//! # Example
28+
//!
29+
//! ```rust
30+
//! use limbic_critic::{Environment, SimpleCritic};
31+
//!
32+
//! struct TradingBot {
33+
//! pnl: f32,
34+
//! market_vol: f32,
35+
//! }
36+
//!
37+
//! impl Environment for TradingBot {
38+
//! fn objective(&self) -> f32 {
39+
//! self.pnl
40+
//! }
41+
//! fn volatility(&self) -> f32 {
42+
//! self.market_vol
43+
//! }
44+
//! }
45+
//!
46+
//! let bot = TradingBot {
47+
//! pnl: 0.6,
48+
//! market_vol: 0.25,
49+
//! };
50+
//! let mods = SimpleCritic::assess(&bot);
51+
//! assert_eq!(mods.dopamine, 0.6);
52+
//! assert_eq!(mods.serotonin, 0.25);
53+
//! ```
954
55+
/// Interface for any external system that a limbic critic can evaluate.
56+
///
57+
/// Implementors provide at least a scalar objective; optional methods supply
58+
/// secondary signals that map onto serotonin, acetylcholine, and
59+
/// norepinephrine channels.
60+
///
61+
/// See the [module-level documentation](self) for the full mapping table and
62+
/// a worked example.
1063
pub trait Environment {
1164
/// Returns the current scalar objective value from the environment.
1265
///
13-
/// This value represents the primary metric that the critic should
14-
/// optimize. It could be profit-and-loss, cross-entropy loss,
15-
/// game score, or any other performance indicator.
66+
/// This is the primary metric the critic optimizes — profit-and-loss,
67+
/// cross-entropy loss (negated), game score, accuracy, or any other
68+
/// performance indicator.
69+
///
70+
/// Prefer a stable, domain-normalized scale when possible. Critics also
71+
/// apply their own clamps / nonlinearities (`clamp`, `tanh`) so raw
72+
/// unnormalized values are accepted, but extreme magnitudes will saturate
73+
/// the resulting modulators.
74+
///
75+
/// # Used by
1676
///
17-
/// The value should be normalized to a consistent range if possible,
18-
/// although the critic's reward shaping functions should also be
19-
/// robust to unnormalized inputs.
77+
/// - [`SimpleCritic`](crate::SimpleCritic): positive values → dopamine in
78+
/// `[0, 1]`; non-positive → dopamine `0`.
79+
/// - [`TDCritic`](crate::TDCritic): difference from the previous objective
80+
/// drives the TD error and thus dopamine / acetylcholine.
2081
fn objective(&self) -> f32;
2182

22-
/// Returns a scalar value representing environmental volatility or risk.
83+
/// Returns environmental volatility or risk.
2384
///
24-
/// This is optional and can be used to modulate serotonin levels.
25-
/// For a trading bot, this might be market volatility.
26-
/// For a game, it could be the number of enemies on screen.
27-
/// Defaults to 0.0 if not implemented.
85+
/// Optional. Mapped to **serotonin** (clamped to `[0.0, 1.0]`) by both
86+
/// critics. Defaults to `0.0` if not overridden.
87+
///
88+
/// Domain examples:
89+
/// - Trading: realized or implied market volatility.
90+
/// - Games: number of threats / enemy density.
91+
/// - Training: loss variance over a recent window.
2892
fn volatility(&self) -> f32 {
2993
0.0
3094
}
3195

32-
/// Returns a scalar value representing environmental surprise or novelty.
96+
/// Returns environmental surprise or novelty.
97+
///
98+
/// Optional. Used by [`SimpleCritic`](crate::SimpleCritic) as the source
99+
/// of **acetylcholine** (clamped to `[0.0, 1.0]`).
100+
/// [`TDCritic`](crate::TDCritic) ignores this method and instead derives
101+
/// acetylcholine from `abs(td_error).tanh()`. Defaults to `0.0` if not
102+
/// overridden.
33103
///
34-
/// This is optional and can be used to modulate acetylcholine levels in
35-
/// stateless critics. For a trading bot, this might be anomaly score.
36-
/// For a game, it could be unexpected state changes or newly discovered
37-
/// entities. Defaults to 0.0 if not implemented.
104+
/// Domain examples:
105+
/// - Trading: anomaly or regime-change score.
106+
/// - Games: unexpected state transitions or newly discovered entities.
107+
/// - Sensors: prediction residual / novelty detector output.
38108
fn surprise(&self) -> f32 {
39109
0.0
40110
}
41111

42-
/// Returns a scalar value representing system stress or instability.
112+
/// Returns system stress or instability.
113+
///
114+
/// Optional. Mapped to **norepinephrine** (clamped to `[0.0, 1.0]`) by
115+
/// both critics. Defaults to `0.0` if not overridden.
43116
///
44-
/// This is optional and can be used to modulate norepinephrine levels.
45-
/// For a hardware system, this might be temperature or power draw.
46-
/// For a software system, it could be error rates or latency.
47-
/// Defaults to 0.0 if not implemented.
117+
/// Domain examples:
118+
/// - Hardware: temperature, power draw, thermal throttling.
119+
/// - Software: error rate, p99 latency, queue depth.
120+
/// - Agents: resource depletion or constraint violation severity.
48121
fn stress(&self) -> f32 {
49122
0.0
50123
}

0 commit comments

Comments
 (0)