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}