Skip to main content

lean_ctx/core/
scent_field.rs

1//! Stigmergic scent field for zero-token multi-agent coordination (#540, EFF-3).
2//!
3//! Agents coordinate indirectly through a shared, time-decaying field of
4//! "scent" deposits instead of reading each other's scratchpad messages
5//! (Many Tems: 3.4x fewer coordination tokens; Pressure Fields 2601.08129:
6//! temporal decay prevents premature convergence). Deposits happen as side
7//! effects of normal work — reads, bounces, claims, handoffs — and the `sync`
8//! view is pure arithmetic over the field: no LLM calls, no message reads.
9//!
10//! Storage: one JSON file under `data_dir/agents/scent_field.json`, guarded by
11//! the same create-new file lock the agent registry uses. Decayed entries are
12//! garbage-collected lazily on every locked operation — no daemon, no timer.
13
14use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16
17/// Scents below this effective intensity are dead and get collected.
18const GC_THRESHOLD: f64 = 0.05;
19/// Superposed intensity per (agent, kind, target) is capped here.
20const INTENSITY_CAP: f64 = 3.0;
21/// A foreign claim is considered active at or above this effective intensity.
22pub const CLAIM_ACTIVE_THRESHOLD: f64 = 0.3;
23/// Max rendered lines in the sync view.
24const SYNC_TOP_K: usize = 15;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ScentKind {
29    /// Agent is actively working on this target (file, task, deploy unit).
30    Claimed,
31    /// Agent finished something related to the target.
32    Done,
33    /// Agent hit a wall here (edit failures, bounces).
34    Stuck,
35    /// Target is being read/touched a lot right now.
36    Hot,
37    /// Target should not be touched (e.g. broken generated file).
38    Avoid,
39}
40
41impl ScentKind {
42    /// Exponential-decay half-life per kind, in seconds.
43    fn half_life_secs(self) -> f64 {
44        match self {
45            ScentKind::Claimed | ScentKind::Hot => 600.0,
46            ScentKind::Stuck => 1800.0,
47            ScentKind::Done | ScentKind::Avoid => 3600.0,
48        }
49    }
50
51    pub fn as_str(self) -> &'static str {
52        match self {
53            ScentKind::Claimed => "CLAIMED",
54            ScentKind::Done => "DONE",
55            ScentKind::Stuck => "STUCK",
56            ScentKind::Hot => "HOT",
57            ScentKind::Avoid => "AVOID",
58        }
59    }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct Scent {
64    pub agent_id: String,
65    pub kind: ScentKind,
66    /// Normalized target: relative file path, task label, or deploy unit.
67    pub target: String,
68    /// Deposited (pre-decay) intensity.
69    pub intensity: f64,
70    /// Unix seconds at deposit time.
71    pub deposited_at: u64,
72}
73
74impl Scent {
75    pub fn effective_intensity(&self, now: u64) -> f64 {
76        let dt = now.saturating_sub(self.deposited_at) as f64;
77        self.intensity * (-(std::f64::consts::LN_2) * dt / self.kind.half_life_secs()).exp()
78    }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
82pub struct ScentField {
83    pub scents: Vec<Scent>,
84    pub schema_version: u32,
85    /// Lifetime count of rejected claims (#549): every rejection is a piece
86    /// of duplicate work the field prevented — the efficacy currency of #540.
87    #[serde(default)]
88    pub claims_rejected: u64,
89}
90
91fn field_path() -> Result<PathBuf, String> {
92    let dir = crate::core::data_dir::lean_ctx_data_dir()?.join("agents");
93    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
94    Ok(dir.join("scent_field.json"))
95}
96
97fn now_secs() -> u64 {
98    std::time::SystemTime::now()
99        .duration_since(std::time::UNIX_EPOCH)
100        .map_or(0, |d| d.as_secs())
101}
102
103/// Scent-field identity (#547). `agent_identity::current_agent_id` falls back
104/// to a shared `"local"` for every unconfigured process, which would make
105/// claims between two parallel MCP servers on the same machine invisible to
106/// each other (`foreign_claim` filters on `agent_id != self`). For scents we
107/// disambiguate with the PID; ledger/heatmap attribution keeps using the
108/// stable shared identity and is intentionally NOT changed.
109#[must_use]
110pub fn scent_agent_id() -> &'static str {
111    static CACHE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
112    CACHE.get_or_init(|| {
113        let base = crate::core::agent_identity::current_agent_id();
114        if base == "local" {
115            format!("local-{}", std::process::id())
116        } else {
117            base.to_string()
118        }
119    })
120}
121
122impl ScentField {
123    fn load_unlocked(path: &PathBuf) -> Self {
124        if let Ok(content) = std::fs::read_to_string(path)
125            && let Ok(f) = serde_json::from_str::<ScentField>(&content)
126        {
127            return f;
128        }
129        ScentField {
130            schema_version: 1,
131            ..Default::default()
132        }
133    }
134
135    fn save_unlocked(&self, path: &PathBuf) -> Result<(), String> {
136        let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
137        std::fs::write(path, json).map_err(|e| e.to_string())
138    }
139
140    /// Drop scents whose effective intensity fell below the GC threshold.
141    pub fn gc(&mut self, now: u64) {
142        self.scents
143            .retain(|s| s.effective_intensity(now) >= GC_THRESHOLD);
144    }
145
146    /// Superpose a deposit: same (agent, kind, target) folds into one scent
147    /// with summed effective intensity (capped), fresh timestamp.
148    pub fn deposit(
149        &mut self,
150        agent_id: &str,
151        kind: ScentKind,
152        target: &str,
153        intensity: f64,
154        now: u64,
155    ) {
156        self.gc(now);
157        let target = target.trim();
158        if target.is_empty() || agent_id.is_empty() {
159            return;
160        }
161        if let Some(existing) = self
162            .scents
163            .iter_mut()
164            .find(|s| s.agent_id == agent_id && s.kind == kind && s.target == target)
165        {
166            let carried = existing.effective_intensity(now);
167            existing.intensity = (carried + intensity).min(INTENSITY_CAP);
168            existing.deposited_at = now;
169        } else {
170            self.scents.push(Scent {
171                agent_id: agent_id.to_string(),
172                kind,
173                target: target.to_string(),
174                intensity: intensity.min(INTENSITY_CAP),
175                deposited_at: now,
176            });
177        }
178    }
179
180    /// Active foreign claim on `target`, if any: returns (agent_id, age_secs).
181    pub fn foreign_claim(&self, target: &str, self_agent: &str, now: u64) -> Option<(String, u64)> {
182        self.scents
183            .iter()
184            .filter(|s| {
185                s.kind == ScentKind::Claimed
186                    && s.target == target
187                    && s.agent_id != self_agent
188                    && s.effective_intensity(now) >= CLAIM_ACTIVE_THRESHOLD
189            })
190            .max_by(|a, b| {
191                a.effective_intensity(now)
192                    .partial_cmp(&b.effective_intensity(now))
193                    .unwrap_or(std::cmp::Ordering::Equal)
194            })
195            .map(|s| (s.agent_id.clone(), now.saturating_sub(s.deposited_at)))
196    }
197
198    /// Arithmetic sync view: targets grouped, intensities superposed across
199    /// agents, sorted by total intensity, capped at SYNC_TOP_K lines.
200    pub fn render_sync(&self, now: u64) -> String {
201        use std::collections::HashMap;
202        // (kind, target) -> (total intensity, agents)
203        type SyncKey<'a> = (ScentKind, &'a str);
204        type SyncAgg<'a> = (f64, Vec<&'a str>);
205        let mut groups: HashMap<SyncKey<'_>, SyncAgg<'_>> = HashMap::new();
206        for s in &self.scents {
207            let eff = s.effective_intensity(now);
208            if eff < GC_THRESHOLD {
209                continue;
210            }
211            let entry = groups.entry((s.kind, s.target.as_str())).or_default();
212            entry.0 += eff;
213            if !entry.1.contains(&s.agent_id.as_str()) {
214                entry.1.push(s.agent_id.as_str());
215            }
216        }
217        if groups.is_empty() {
218            return String::new();
219        }
220        let mut rows: Vec<(SyncKey<'_>, SyncAgg<'_>)> = groups.into_iter().collect();
221        rows.sort_by(|a, b| {
222            b.1.0
223                .partial_cmp(&a.1.0)
224                .unwrap_or(std::cmp::Ordering::Equal)
225                .then_with(|| a.0.1.cmp(b.0.1))
226        });
227
228        let mut out = String::from("Scent field (decaying, zero-token coordination):\n");
229        for ((kind, target), (total, agents)) in rows.iter().take(SYNC_TOP_K) {
230            let who = if agents.len() == 1 {
231                agents[0].to_string()
232            } else {
233                format!("{} agents", agents.len())
234            };
235            out.push_str(&format!(
236                "  {} {} ({:.1}) by {}\n",
237                kind.as_str(),
238                target,
239                total,
240                who
241            ));
242        }
243        let extra = rows.len().saturating_sub(SYNC_TOP_K);
244        if extra > 0 {
245            out.push_str(&format!("  … {extra} weaker scent(s) below cutoff\n"));
246        }
247        out
248    }
249}
250
251/// Locked load-modify-save against the shared field file.
252fn with_field<R>(f: impl FnOnce(&mut ScentField, u64) -> R) -> Result<R, String> {
253    let path = field_path()?;
254    let lock_path = path.with_extension("json.lock");
255    let _lock = crate::core::agents::FileLock::acquire(&lock_path)?;
256    let mut field = ScentField::load_unlocked(&path);
257    let now = now_secs();
258    let result = f(&mut field, now);
259    field.gc(now);
260    field.save_unlocked(&path)?;
261    Ok(result)
262}
263
264/// Deposit a scent as a side effect of normal work. Errors are swallowed —
265/// coordination hints must never break the primary operation.
266pub fn deposit(agent_id: &str, kind: ScentKind, target: &str, intensity: f64) {
267    let _ = with_field(|field, now| field.deposit(agent_id, kind, target, intensity, now));
268}
269
270/// Atomic claim: fails with the holder's id if another agent's claim is still
271/// active, otherwise deposits a strong Claimed scent for `agent_id`.
272pub fn claim(agent_id: &str, target: &str) -> Result<(), String> {
273    with_field(|field, now| {
274        if let Some((holder, age)) = field.foreign_claim(target, agent_id, now) {
275            field.claims_rejected += 1;
276            return Err(format!(
277                "already claimed by {holder} ({}m ago, still active)",
278                age / 60
279            ));
280        }
281        field.deposit(agent_id, ScentKind::Claimed, target, 2.0, now);
282        Ok(())
283    })?
284}
285
286/// Lifetime rejected-claim counter (#549): duplicate work prevented.
287pub fn claims_rejected_total() -> u64 {
288    field_path().map_or(0, |p| ScentField::load_unlocked(&p).claims_rejected)
289}
290
291/// Read-only view of currently effective scents for the dashboard (#548):
292/// `(scent, effective_intensity_now)`, strongest first. Lock-free read —
293/// a slightly stale view is fine for display.
294pub fn active_scents() -> Vec<(Scent, f64)> {
295    let Ok(path) = field_path() else {
296        return Vec::new();
297    };
298    let field = ScentField::load_unlocked(&path);
299    let now = now_secs();
300    let mut v: Vec<(Scent, f64)> = field
301        .scents
302        .into_iter()
303        .filter_map(|s| {
304            let eff = s.effective_intensity(now);
305            (eff >= GC_THRESHOLD).then_some((s, eff))
306        })
307        .collect();
308    v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
309    v
310}
311
312/// Release a claim (and any Hot scent) on `target` held by `agent_id`.
313pub fn release(agent_id: &str, target: &str) {
314    let _ = with_field(|field, now| {
315        field.scents.retain(|s| {
316            !(s.agent_id == agent_id && s.target == target && s.kind == ScentKind::Claimed)
317        });
318        field.gc(now);
319    });
320}
321
322/// One-line hint for ctx_read when someone else actively claimed this path.
323/// Costs ~10 tokens and prevents duplicate work.
324pub fn read_hint(path: &str, self_agent: &str) -> Option<String> {
325    let field_file = field_path().ok()?;
326    // Read-only fast path: no lock needed for a hint; stale reads are fine.
327    let field = ScentField::load_unlocked(&field_file);
328    let now = now_secs();
329    let rel = crate::core::pathutil::normalize_tool_path(path);
330    let (holder, age) = field.foreign_claim(&rel, self_agent, now)?;
331    Some(format!("[scent: claimed by {holder} {}m ago]", age / 60))
332}
333
334/// Arithmetic sync block for ctx_agent sync.
335pub fn sync_block() -> String {
336    let Ok(path) = field_path() else {
337        return String::new();
338    };
339    let field = ScentField::load_unlocked(&path);
340    field.render_sync(now_secs())
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    const NOW: u64 = 1_780_000_000;
348
349    #[test]
350    fn decay_halves_at_half_life() {
351        let s = Scent {
352            agent_id: "a1".into(),
353            kind: ScentKind::Hot,
354            target: "src/x.rs".into(),
355            intensity: 1.0,
356            deposited_at: NOW,
357        };
358        let eff = s.effective_intensity(NOW + 600);
359        assert!((eff - 0.5).abs() < 0.01, "half-life decay, got {eff}");
360        // After two half-lives < 0.3 (ticket acceptance).
361        assert!(s.effective_intensity(NOW + 1200) < 0.3);
362    }
363
364    #[test]
365    fn superposition_caps_intensity() {
366        let mut f = ScentField::default();
367        for _ in 0..20 {
368            f.deposit("a1", ScentKind::Hot, "src/x.rs", 0.3, NOW);
369        }
370        assert_eq!(f.scents.len(), 1);
371        assert!(f.scents[0].intensity <= INTENSITY_CAP + f64::EPSILON);
372    }
373
374    #[test]
375    fn gc_drops_dead_scents() {
376        let mut f = ScentField::default();
377        f.deposit("a1", ScentKind::Hot, "src/x.rs", 0.3, NOW);
378        f.gc(NOW + 6 * 600); // six half-lives: 0.3 -> ~0.0047
379        assert!(f.scents.is_empty());
380    }
381
382    #[test]
383    fn foreign_claim_detected_and_own_ignored() {
384        let mut f = ScentField::default();
385        f.deposit("a1", ScentKind::Claimed, "src/x.rs", 2.0, NOW);
386        assert!(f.foreign_claim("src/x.rs", "a2", NOW + 60).is_some());
387        assert!(f.foreign_claim("src/x.rs", "a1", NOW + 60).is_none());
388        // Expired claim no longer blocks.
389        assert!(f.foreign_claim("src/x.rs", "a2", NOW + 3 * 600).is_none());
390    }
391
392    #[test]
393    fn sync_view_caps_lines_and_superposes() {
394        let mut f = ScentField::default();
395        for i in 0..50 {
396            f.deposit("a1", ScentKind::Hot, &format!("src/f{i}.rs"), 0.5, NOW);
397        }
398        f.deposit("a2", ScentKind::Hot, "src/f0.rs", 0.5, NOW);
399        let view = f.render_sync(NOW);
400        let lines: Vec<&str> = view.lines().collect();
401        assert!(
402            lines.len() <= SYNC_TOP_K + 2,
403            "header + topk + overflow, got {}",
404            lines.len()
405        );
406        assert!(view.contains("2 agents"), "superposed line: {view}");
407        assert!(view.contains("weaker scent"));
408    }
409
410    #[test]
411    fn empty_field_renders_empty() {
412        let f = ScentField::default();
413        assert!(f.render_sync(NOW).is_empty());
414    }
415
416    #[test]
417    fn scent_identity_disambiguates_unconfigured_processes() {
418        let id = scent_agent_id();
419        let base = crate::core::agent_identity::current_agent_id();
420        if base == "local" {
421            // #547: two parallel unconfigured processes must not collide.
422            assert_eq!(id, format!("local-{}", std::process::id()));
423        } else {
424            // Explicitly configured identity is kept verbatim.
425            assert_eq!(id, base);
426        }
427        // Claims between distinct PIDs are mutually foreign.
428        let mut f = ScentField::default();
429        f.deposit("local-1111", ScentKind::Claimed, "src/x.rs", 2.0, NOW);
430        assert!(
431            f.foreign_claim("src/x.rs", "local-2222", NOW + 30)
432                .is_some()
433        );
434    }
435}