Skip to main content

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}