formal_ai/learning_ledger.rs
1//! The human-gated promotion ledger for the self-healing loop (issue #558).
2//!
3//! Issue #558 asks for a self-healing algorithm that *"promotes improvements when
4//! tests and the user accept them"* and writes the accepted result *"to mainline
5//! history as an approved learning record"*. [`crate::self_healing`] closes the
6//! reasoning loop up to a reviewable [`RepairCase`]; this module supplies the
7//! **terminal promotion step** the issue requires: a durable, append-only ledger
8//! of lessons that were *both* benchmark-green *and* approved by a human.
9//!
10//! The gate is deliberately strict and models the issue's two acceptance
11//! conditions as one operation. A [`RepairCase`] can only be promoted when:
12//!
13//! * its source ↔ links round-trip is faithful (so an accepted edit could in
14//! principle be recompiled — the "recompile itself" guardrail), and
15//! * the benchmark gate passed and a lesson is adoptable
16//! ([`RepairOutcome::AwaitingReview`]) — *"when tests … accept"*, and
17//! * a human explicitly approves ([`HumanApproval::is_granted`]) — *"and the
18//! user accept\[s\]"*.
19//!
20//! Nothing is promoted automatically: [`LearningLedger::promote`] takes an explicit
21//! [`HumanApproval`] and refuses every case that is not green *and* approved. Once
22//! promoted, [`LearningLedger::lesson_for`] lets the system recognise a *repeated*
23//! failure and recall the already-approved lesson instead of re-deriving it — the
24//! concrete payoff of "auto learning": a failure seen once and approved is answered
25//! from the ledger the next time. Every field is a deterministic function of the
26//! repair case and the approval, so the ledger and its content id are reproducible.
27
28use std::fmt::Write as _;
29
30use crate::engine::stable_id;
31use crate::self_healing::{RepairCase, RepairOutcome};
32
33/// An explicit, auditable human decision on a reviewed [`RepairCase`].
34///
35/// Promotion is *"when tests and the user accept"* — the benchmark gate covers
36/// "tests"; this value carries the "user" half. It records who reviewed and whether
37/// they granted adoption, so the ledger entry is attributable.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct HumanApproval {
40 reviewer: String,
41 granted: bool,
42}
43
44impl HumanApproval {
45 /// A granted approval from `reviewer` — the human accepts the lesson.
46 #[must_use]
47 pub fn granted(reviewer: impl Into<String>) -> Self {
48 Self {
49 reviewer: reviewer.into(),
50 granted: true,
51 }
52 }
53
54 /// A withheld approval from `reviewer` — the human declines the lesson.
55 #[must_use]
56 pub fn declined(reviewer: impl Into<String>) -> Self {
57 Self {
58 reviewer: reviewer.into(),
59 granted: false,
60 }
61 }
62
63 /// Whether the human accepted the lesson.
64 #[must_use]
65 pub const fn is_granted(&self) -> bool {
66 self.granted
67 }
68
69 /// Who made the decision.
70 #[must_use]
71 pub fn reviewer(&self) -> &str {
72 &self.reviewer
73 }
74}
75
76/// Why a [`RepairCase`] could not be promoted into the ledger.
77///
78/// Every variant is a guardrail: the loop stays proposal-only until *both* the
79/// tests and the human accept, and never records a lesson it could not recompile.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum PromotionRejected {
82 /// No lesson could be synthesised from the failure ([`RepairOutcome::NoCandidate`]).
83 NoReviewableProposal,
84 /// A lesson exists but the benchmark gate did not pass
85 /// ([`RepairOutcome::BlockedByBenchmark`]) — "tests" did not accept.
86 TestsNotGreen,
87 /// The mapped source did not round-trip byte-for-byte, so an accepted edit
88 /// could not be recompiled faithfully; adoption is blocked.
89 SourceNotFaithful,
90 /// The human withheld approval — "the user" did not accept.
91 HumanDeclined,
92 /// A lesson for this exact failure is already in the ledger; promotion is
93 /// idempotent and refuses to record a duplicate.
94 AlreadyPromoted,
95}
96
97impl PromotionRejected {
98 /// A stable, human-readable slug for the rejection reason.
99 #[must_use]
100 pub const fn slug(self) -> &'static str {
101 match self {
102 Self::NoReviewableProposal => "no_reviewable_proposal",
103 Self::TestsNotGreen => "tests_not_green",
104 Self::SourceNotFaithful => "source_not_faithful",
105 Self::HumanDeclined => "human_declined",
106 Self::AlreadyPromoted => "already_promoted",
107 }
108 }
109}
110
111/// One promoted lesson — the "approved learning record" issue #558 calls for.
112///
113/// It flattens the parts of a green, approved [`RepairCase`] a future lookup needs:
114/// the failure it answers, the source it maps onto, the adopted rule, and the human
115/// who approved it. Deterministic: every field comes from the case and approval.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct LedgerEntry {
118 /// Stable content-addressed id of the promoted lesson.
119 pub lesson_id: String,
120 /// The originating repair-case id (provenance back to the reasoning loop).
121 pub case_id: String,
122 /// The original input the system could not answer — the lookup key.
123 pub failure_prompt: String,
124 /// The source module the failure maps onto.
125 pub module_path: String,
126 /// The adopted learned rule id.
127 pub rule_id: String,
128 /// The program-plan task the learned rule resolves the failure to.
129 pub resolved_task: String,
130 /// The modifier that triggers the learned rule.
131 pub modifier: String,
132 /// The benchmark suite that gated adoption.
133 pub benchmark_suite: String,
134 /// Passing case count from the gate run that green-lit adoption.
135 pub benchmark_passed: usize,
136 /// The human who approved promotion.
137 pub reviewer: String,
138}
139
140impl LedgerEntry {
141 /// A one-line human-readable summary of the promoted lesson.
142 #[must_use]
143 pub fn summary(&self) -> String {
144 format!(
145 "Approved lesson `{}` (rule `{}`) for failure `{}` → `{}`, mapped onto {}, gated by {} passing case(s), approved by {}.",
146 self.lesson_id,
147 self.rule_id,
148 self.failure_prompt,
149 self.resolved_task,
150 self.module_path,
151 self.benchmark_passed,
152 self.reviewer,
153 )
154 }
155}
156
157/// The durable, append-only ledger of human-approved lessons.
158///
159/// Built by promoting green, approved [`RepairCase`]s. Entries are kept in
160/// promotion order and the whole ledger serialises to Links Notation with a stable
161/// content id, so it can be committed as an auditable artifact and re-derived.
162#[derive(Debug, Clone, Default, PartialEq, Eq)]
163pub struct LearningLedger {
164 entries: Vec<LedgerEntry>,
165}
166
167impl LearningLedger {
168 /// An empty ledger.
169 #[must_use]
170 pub const fn new() -> Self {
171 Self {
172 entries: Vec::new(),
173 }
174 }
175
176 /// The promoted lessons, in promotion order.
177 #[must_use]
178 pub fn entries(&self) -> &[LedgerEntry] {
179 &self.entries
180 }
181
182 /// How many lessons have been promoted.
183 #[must_use]
184 pub const fn len(&self) -> usize {
185 self.entries.len()
186 }
187
188 /// Whether the ledger holds no lessons yet.
189 #[must_use]
190 pub const fn is_empty(&self) -> bool {
191 self.entries.is_empty()
192 }
193
194 /// Promote `case` into the ledger — the terminal, human-gated step of the loop.
195 ///
196 /// Succeeds only when the case is benchmark-green with an adoptable lesson, its
197 /// source round-trips faithfully, and `approval` is granted. Returns the newly
198 /// recorded [`LedgerEntry`], or a [`PromotionRejected`] explaining which gate
199 /// stopped it. Idempotent per failure prompt: a second promotion of the same
200 /// failure is refused as [`PromotionRejected::AlreadyPromoted`].
201 pub fn promote(
202 &mut self,
203 case: &RepairCase,
204 approval: &HumanApproval,
205 ) -> Result<&LedgerEntry, PromotionRejected> {
206 // Tests-accept gate: only a benchmark-green, adoptable case is reviewable.
207 match case.outcome {
208 RepairOutcome::AwaitingReview => {}
209 RepairOutcome::BlockedByBenchmark => return Err(PromotionRejected::TestsNotGreen),
210 RepairOutcome::NoCandidate => return Err(PromotionRejected::NoReviewableProposal),
211 }
212 // Recompile guardrail: never record a lesson whose source cannot be
213 // reconstructed byte-for-byte.
214 if !case.source_round_trip.faithful {
215 return Err(PromotionRejected::SourceNotFaithful);
216 }
217 // User-accept gate.
218 if !approval.is_granted() {
219 return Err(PromotionRejected::HumanDeclined);
220 }
221 // Idempotency: one approved lesson per distinct failure.
222 if self.knows(&case.failure_prompt) {
223 return Err(PromotionRejected::AlreadyPromoted);
224 }
225
226 // `AwaitingReview` guarantees at least one adoptable rule; take the first.
227 let rule = case
228 .learning
229 .adoptable_rules()
230 .into_iter()
231 .next()
232 .ok_or(PromotionRejected::NoReviewableProposal)?;
233 let lesson_id = stable_id(
234 "promoted_lesson",
235 &format!("{}:{}:{}", case.id, rule.rule_id, approval.reviewer()),
236 );
237 self.entries.push(LedgerEntry {
238 lesson_id,
239 case_id: case.id.clone(),
240 failure_prompt: case.failure_prompt.clone(),
241 module_path: case.source_round_trip.module_path.clone(),
242 rule_id: rule.rule_id.clone(),
243 resolved_task: rule.resolved_task.clone(),
244 modifier: rule.modifier.clone(),
245 benchmark_suite: case.learning.gate.suite_id.clone(),
246 benchmark_passed: case.learning.gate.passed,
247 reviewer: approval.reviewer().to_owned(),
248 });
249 Ok(self.entries.last().expect("just pushed"))
250 }
251
252 /// The approved lesson for a *repeated* failure, if one was promoted.
253 ///
254 /// This is what makes the loop "auto learning": a failure the system once could
255 /// not answer, then learned and had approved, is now recognised and answered
256 /// from the ledger without re-deriving it. Matching is on the normalised prompt
257 /// (trimmed, case-insensitive) so trivial rephrasings of whitespace/case hit.
258 #[must_use]
259 pub fn lesson_for(&self, prompt: &str) -> Option<&LedgerEntry> {
260 let needle = normalise(prompt);
261 self.entries
262 .iter()
263 .find(|entry| normalise(&entry.failure_prompt) == needle)
264 }
265
266 /// Whether the ledger already holds an approved lesson for `prompt`.
267 #[must_use]
268 pub fn knows(&self, prompt: &str) -> bool {
269 self.lesson_for(prompt).is_some()
270 }
271
272 /// Render the whole ledger as Links Notation — the committable, auditable
273 /// "mainline history" of approved learning records. Ends trimmed.
274 #[must_use]
275 pub fn links_notation(&self) -> String {
276 let mut out = String::from("learning_ledger\n");
277 field(&mut out, "engine", "meta_language");
278 field(&mut out, "human_gated", "true");
279 let _ = writeln!(out, " lesson_count \"{}\"", self.entries.len());
280 for entry in &self.entries {
281 out.push_str(" lesson\n");
282 nested(&mut out, "lesson_id", &entry.lesson_id);
283 nested(&mut out, "case_id", &entry.case_id);
284 nested(&mut out, "failure_prompt", &entry.failure_prompt);
285 nested(&mut out, "module_path", &entry.module_path);
286 nested(&mut out, "rule_id", &entry.rule_id);
287 nested(&mut out, "modifier", &entry.modifier);
288 nested(&mut out, "resolved_task", &entry.resolved_task);
289 nested(&mut out, "benchmark_suite", &entry.benchmark_suite);
290 let _ = writeln!(out, " benchmark_passed \"{}\"", entry.benchmark_passed);
291 nested(&mut out, "reviewer", &entry.reviewer);
292 }
293 out.trim_end().to_owned()
294 }
295
296 /// A stable content id over the ledger's Links Notation — a fingerprint of the
297 /// whole approved-lesson history.
298 #[must_use]
299 pub fn content_id(&self) -> String {
300 stable_id("learning_ledger", &self.links_notation())
301 }
302}
303
304/// Build the canonical, fully-worked ledger: the canonical self-healing case
305/// promoted with a granted approval.
306///
307/// Deterministic and self-contained — used by the committed
308/// `data/meta/learning-ledger.lino` artifact, the example, and the tests. Panics
309/// only if the canonical case is not promotable, which would itself be a bug.
310#[must_use]
311pub fn canonical_ledger() -> LearningLedger {
312 let mut ledger = LearningLedger::new();
313 ledger
314 .promote(
315 &crate::self_healing::canonical_case(),
316 &HumanApproval::granted("maintainer"),
317 )
318 .expect("the canonical self-healing case is green and approvable");
319 ledger
320}
321
322fn normalise(prompt: &str) -> String {
323 prompt.trim().to_lowercase()
324}
325
326fn field(out: &mut String, key: &str, value: &str) {
327 let _ = writeln!(out, " {key} \"{}\"", quote(value));
328}
329
330fn nested(out: &mut String, key: &str, value: &str) {
331 let _ = writeln!(out, " {key} \"{}\"", quote(value));
332}
333
334fn quote(value: &str) -> String {
335 value
336 .replace('\\', "\\\\")
337 .replace('"', "'")
338 .replace('\n', "\\n")
339 .replace('\r', "\\r")
340 .replace('\t', "\\t")
341}