Skip to main content

loopsmith_core/config/
memory.rs

1//! What the loop remembers, for how long, and what it takes to promote a
2//! recollection into something reused.
3//!
4//! This section absorbs the old `context` block. That block answered only
5//! "how much of the last few iterations does the next prompt carry" — a
6//! question about one run. The namespaces below answer the other half: what
7//! survives the run, and under what evidence.
8//!
9//! The distinction that matters is between *observed* and *believed*. An
10//! episode is observed: it happened, and recording it costs nothing. A
11//! procedure is believed: the loop is asserting this works, and acting on it
12//! later. Promotion is the boundary between the two, and it is deliberately not
13//! free — a loop that promotes its first success into a standing procedure has
14//! learned a superstition.
15
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18
19use super::yes;
20
21/// What it takes for a record to move from observed to reusable.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
23#[serde(tag = "rule", rename_all = "snake_case", deny_unknown_fields)]
24pub enum Promotion {
25    /// Never reused beyond the run that wrote it.
26    Never {},
27    /// Reusable as soon as it is written. Only appropriate where writing is
28    /// itself the evidence — a recorded failure mode, for instance.
29    Automatic {},
30    /// Reusable once the same record has been independently corroborated this
31    /// many times.
32    RepeatedValidation {
33        #[serde(default = "default_times")]
34        times: u32,
35    },
36    /// Reusable only after a human says so.
37    HumanApproval {},
38}
39
40fn default_times() -> u32 {
41    3
42}
43
44/// Retention and promotion policy for one namespace.
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46#[serde(deny_unknown_fields)]
47pub struct NamespacePolicy {
48    #[serde(default = "yes")]
49    pub enabled: bool,
50    /// Drop records older than this. `None` keeps them for the life of the
51    /// store, which is the right answer for episodic history and the wrong one
52    /// for anything the loop acts on.
53    #[serde(default)]
54    pub retention_days: Option<u32>,
55    #[serde(default = "default_promotion")]
56    pub promotion: Promotion,
57    /// A record below this confidence is never returned by retrieval.
58    #[serde(default = "default_min_confidence")]
59    pub min_confidence: f64,
60    /// Refuse to write a record that cannot say where it came from.
61    #[serde(default = "yes")]
62    pub require_provenance: bool,
63}
64
65fn default_promotion() -> Promotion {
66    Promotion::RepeatedValidation {
67        times: default_times(),
68    }
69}
70fn default_min_confidence() -> f64 {
71    0.75
72}
73
74impl Default for NamespacePolicy {
75    fn default() -> Self {
76        Self {
77            enabled: true,
78            retention_days: None,
79            promotion: default_promotion(),
80            min_confidence: default_min_confidence(),
81            require_provenance: true,
82        }
83    }
84}
85
86/// The four kinds of thing worth remembering.
87///
88/// Splitting them is what makes differing retention defensible. Episodes are
89/// cheap and disposable; procedures are expensive and load-bearing. One
90/// retention policy across both either throws away what the loop learned or
91/// keeps every transcript forever.
92#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
93#[serde(deny_unknown_fields)]
94pub struct Namespaces {
95    /// What happened: dispatches, outputs, verdicts. Written always, promoted
96    /// never — an episode is evidence for a belief, not a belief.
97    #[serde(default = "episodic_default")]
98    pub episodic: NamespacePolicy,
99    /// Stable facts about the domain the loop works in.
100    #[serde(default)]
101    pub semantic: NamespacePolicy,
102    /// Reusable ways of doing things that have worked before.
103    #[serde(default)]
104    pub procedural: NamespacePolicy,
105    /// Known failure modes and what got past them. Promoted automatically:
106    /// having hit a wall is self-evidencing, and the cost of re-learning it is
107    /// the whole reason the section exists.
108    #[serde(default = "failure_default")]
109    pub failure: NamespacePolicy,
110}
111
112fn episodic_default() -> NamespacePolicy {
113    NamespacePolicy {
114        promotion: Promotion::Never {},
115        require_provenance: false,
116        ..NamespacePolicy::default()
117    }
118}
119
120fn failure_default() -> NamespacePolicy {
121    NamespacePolicy {
122        promotion: Promotion::Automatic {},
123        ..NamespacePolicy::default()
124    }
125}
126
127impl Default for Namespaces {
128    fn default() -> Self {
129        Self {
130            episodic: episodic_default(),
131            semantic: NamespacePolicy::default(),
132            procedural: NamespacePolicy::default(),
133            failure: failure_default(),
134        }
135    }
136}
137
138/// The whole memory policy: carry-forward within a run, namespaces across runs.
139#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
140#[serde(deny_unknown_fields)]
141pub struct MemoryPolicy {
142    /// How many previous iteration summaries a node's prompt carries.
143    ///
144    /// `0` disables carry-forward entirely. The default of 2 is enough for a
145    /// node to see what it just tried and what it tried before that, which is
146    /// what "do not repeat yourself" needs, without the prompt growing with the
147    /// run.
148    #[serde(default = "default_carry")]
149    pub carry_summaries: usize,
150    /// Provider id used to write the optional narrative half of a summary.
151    ///
152    /// Omit it and summaries are still written — the deterministic facts are
153    /// always there. This only buys prose, and prose costs tokens every
154    /// iteration, so it is opt-in.
155    #[serde(default)]
156    pub summary_provider: Option<String>,
157    /// Ceiling on the narrative, in characters. A summary that grows without
158    /// limit defeats the purpose of having one.
159    #[serde(default = "default_max_chars")]
160    pub max_summary_chars: usize,
161    /// Cross-run memory.
162    #[serde(default)]
163    pub namespaces: Namespaces,
164    /// Most records a single retrieval may return.
165    #[serde(default = "default_max_retrieved")]
166    pub max_retrieved: usize,
167}
168
169fn default_carry() -> usize {
170    2
171}
172fn default_max_chars() -> usize {
173    1200
174}
175fn default_max_retrieved() -> usize {
176    10
177}
178
179impl Default for MemoryPolicy {
180    fn default() -> Self {
181        Self {
182            carry_summaries: default_carry(),
183            summary_provider: None,
184            max_summary_chars: default_max_chars(),
185            namespaces: Namespaces::default(),
186            max_retrieved: default_max_retrieved(),
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn episodes_are_never_promoted_by_default() {
197        // An episode is evidence, not a conclusion. Promoting one would let a
198        // single run's transcript become a standing belief.
199        assert_eq!(Namespaces::default().episodic.promotion, Promotion::Never {});
200    }
201
202    #[test]
203    fn failures_are_promoted_automatically_by_default() {
204        // Re-learning a wall the loop already hit is the waste this exists to
205        // prevent, and hitting it is its own evidence.
206        assert_eq!(
207            Namespaces::default().failure.promotion,
208            Promotion::Automatic {}
209        );
210    }
211
212    #[test]
213    fn beliefs_need_corroboration_by_default() {
214        for p in [
215            Namespaces::default().semantic.promotion,
216            Namespaces::default().procedural.promotion,
217        ] {
218            assert!(
219                matches!(p, Promotion::RepeatedValidation { times } if times > 1),
220                "a belief should need more than one sighting, got {p:?}"
221            );
222        }
223    }
224}