Skip to main content

formal_ai/
self_improvement.rs

1//! White-box self-improvement over accumulated unknown traces.
2//!
3//! Issue #364 closes the #349 roadmap loop: traces produced by the unknown
4//! path can be accumulated, inspected, converted into candidate seed rules, and
5//! gated by the coding-modification benchmark before adoption. This module
6//! deliberately stops at proposing Links Notation seed rules; writing them back
7//! to `data/seed/` remains a review step so the learned artifact is auditable.
8
9use std::fmt::Write as _;
10
11use crate::engine::{stable_id, SymbolicAnswer};
12use crate::event_log::{Event, EventLog};
13use crate::substitution::SubstitutionRuleSet;
14
15const CODING_MODIFICATION_SUITE_LINO: &str =
16    include_str!("../data/benchmarks/coding-modification-suite.lino");
17
18/// A solver trace that reached or started from the unknown path.
19///
20/// The trace stores structured [`EventLog`] events rather than only the
21/// flattened answer record so rule synthesis can recover candidate, verification,
22/// and program-plan payloads without reparsing display text.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct UnknownTrace {
25    /// Stable content-addressed trace id.
26    pub id: String,
27    /// Original user prompt for triage and human review.
28    pub prompt: String,
29    /// Ordered solver events captured from diagnostics/event-log output.
30    pub events: Vec<Event>,
31}
32
33impl UnknownTrace {
34    /// Create a trace from an event log known to be relevant.
35    #[must_use]
36    pub fn new(prompt: impl Into<String>, events: Vec<Event>) -> Self {
37        let prompt = prompt.into();
38        let fingerprint = events
39            .iter()
40            .map(|event| format!("{}={}", event.kind, event.payload))
41            .collect::<Vec<_>>()
42            .join("\n");
43        let id = stable_id("unknown_trace", &format!("{prompt}\n{fingerprint}"));
44        Self { id, prompt, events }
45    }
46
47    /// Accumulate a trace only when the event log proves the unknown path was
48    /// involved.
49    #[must_use]
50    pub fn from_event_log(prompt: &str, intent: &str, log: &EventLog) -> Option<Self> {
51        let involved_unknown_path = intent == "unknown"
52            || log.events().iter().any(|event| {
53                event.kind == "reasoning:unknown"
54                    || (event.kind == "selected_rule" && event.payload.contains("initial unknown"))
55            });
56        involved_unknown_path.then(|| Self::new(prompt, log.events().to_vec()))
57    }
58
59    /// Build a minimal trace record from a public answer. This preserves the
60    /// flattened Links Notation answer and evidence links when the caller no
61    /// longer has the original event log.
62    #[must_use]
63    pub fn from_symbolic_answer(prompt: &str, answer: &SymbolicAnswer) -> Option<Self> {
64        if answer.intent != "unknown" && !answer.links_notation.contains("initial unknown") {
65            return None;
66        }
67        let mut log = EventLog::new();
68        log.append("answer:intent", answer.intent.clone());
69        log.append("answer:evidence", answer.evidence_links.join("\n"));
70        log.append("answer:links_notation", answer.links_notation.clone());
71        Some(Self::new(prompt, log.events().to_vec()))
72    }
73
74    /// Render the accumulated trace as human-readable Links Notation.
75    #[must_use]
76    pub fn links_notation(&self) -> String {
77        let mut out = String::from("unknown_trace\n");
78        push_quoted_field(&mut out, "id", &self.id);
79        push_quoted_field(&mut out, "prompt", &self.prompt);
80        let _ = writeln!(out, "  event_count \"{}\"", self.events.len());
81        for event in &self.events {
82            out.push_str("  event\n");
83            push_quoted_nested_field(&mut out, "kind", event.kind);
84            push_quoted_nested_field(&mut out, "id", &event.id);
85            push_quoted_nested_field(&mut out, "payload", &event.payload);
86        }
87        out.trim_end().to_owned()
88    }
89}
90
91/// Summary from the benchmark gate that must pass before a learned rule can be
92/// adopted.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct BenchmarkGateReport {
95    /// Benchmark suite id, normally `issue_362_multilingual_coding_modification`.
96    pub suite_id: String,
97    /// Local/CI command that produced the report.
98    pub runner: String,
99    /// Passing case count from the gate run.
100    pub passed: usize,
101    /// Failing case count from the gate run.
102    pub failed: usize,
103    /// Minimum pass count recorded by the ratchet.
104    pub minimum_pass_count: usize,
105}
106
107impl BenchmarkGateReport {
108    /// Construct a gate report from explicit counts.
109    #[must_use]
110    pub fn new(
111        suite_id: impl Into<String>,
112        runner: impl Into<String>,
113        passed: usize,
114        failed: usize,
115        minimum_pass_count: usize,
116    ) -> Self {
117        Self {
118            suite_id: suite_id.into(),
119            runner: runner.into(),
120            passed,
121            failed,
122            minimum_pass_count,
123        }
124    }
125
126    /// Build an issue #362 gate report using the checked-in benchmark manifest.
127    ///
128    /// The caller supplies the latest pass/fail counts; the suite id, runner,
129    /// and ratchet floor are read from `data/benchmarks/coding-modification-suite.lino`.
130    #[must_use]
131    pub fn issue_362_from_counts(passed: usize, failed: usize) -> Self {
132        let suite = parse_first_record(CODING_MODIFICATION_SUITE_LINO)
133            .expect("coding-modification suite fixture should contain a suite record");
134        let suite_id = suite
135            .field("id")
136            .unwrap_or("issue_362_multilingual_coding_modification")
137            .to_owned();
138        let runner = suite
139            .field("runner")
140            .unwrap_or("cargo test --test unit issue_362_multilingual_multi_turn_coding_modification_ratchet -- --nocapture")
141            .to_owned();
142        let minimum_pass_count = suite
143            .field("minimum_pass_count")
144            .and_then(|value| value.parse::<usize>().ok())
145            .unwrap_or(1);
146        Self::new(suite_id, runner, passed, failed, minimum_pass_count)
147    }
148
149    /// Whether the gate allows learned-rule adoption.
150    #[must_use]
151    pub const fn permits_adoption(&self) -> bool {
152        self.passed >= self.minimum_pass_count
153    }
154
155    const fn status_slug(&self) -> &'static str {
156        if self.permits_adoption() {
157            "passed"
158        } else {
159            "failed"
160        }
161    }
162}
163
164/// Attempt to learn seed rules from accumulated unknown traces.
165#[must_use]
166pub fn learn_rules_from_unknown_traces(
167    traces: &[UnknownTrace],
168    gate: BenchmarkGateReport,
169) -> LearningRun {
170    let mut proposals = Vec::new();
171    let mut rejections = Vec::new();
172
173    for trace in traces {
174        match propose_rule_from_trace(trace, &gate) {
175            Ok(proposal) => proposals.push(proposal),
176            Err(reason) => rejections.push(LearningRejection {
177                trace_id: trace.id.clone(),
178                reason,
179            }),
180        }
181    }
182
183    LearningRun::new(gate, traces.len(), proposals, rejections)
184}
185
186/// One learned seed-rule candidate.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct LearnedRuleProposal {
189    /// Stable proposal id.
190    pub id: String,
191    /// Unknown trace that produced the proposal.
192    pub trace_id: String,
193    /// Candidate rule id.
194    pub rule_id: String,
195    /// Program-plan task before rewriting.
196    pub base_task: String,
197    /// Modifier that triggers the learned rule.
198    pub modifier: String,
199    /// Program-plan task after rewriting.
200    pub resolved_task: String,
201    /// Verification fixture named by the rule-synthesis trace.
202    pub fixture: String,
203    /// Human-readable review summary.
204    pub summary: String,
205    /// Learned rule represented as Links Notation.
206    pub seed_rule_lino: String,
207    /// Adoption state after verification and benchmark gating.
208    pub adoption: LearnedRuleAdoption,
209}
210
211/// Whether a learned rule can be adopted.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum LearnedRuleAdoption {
214    /// Rule verification passed and the benchmark ratchet did not regress.
215    Adoptable,
216    /// Rule verification passed, but the benchmark gate did not.
217    BlockedByBenchmark,
218}
219
220impl LearnedRuleAdoption {
221    const fn slug(self) -> &'static str {
222        match self {
223            Self::Adoptable => "adoptable",
224            Self::BlockedByBenchmark => "blocked_by_benchmark",
225        }
226    }
227}
228
229/// A trace that could not produce a learned rule.
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct LearningRejection {
232    /// Unknown trace id.
233    pub trace_id: String,
234    /// Human-readable rejection reason.
235    pub reason: String,
236}
237
238/// Complete self-improvement run.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct LearningRun {
241    /// Stable run id.
242    pub id: String,
243    /// Number of traces considered.
244    pub trace_count: usize,
245    /// Benchmark gate used for adoption.
246    pub gate: BenchmarkGateReport,
247    /// Proposed learned seed rules.
248    pub proposals: Vec<LearnedRuleProposal>,
249    /// Traces that could not be converted into rules.
250    pub rejections: Vec<LearningRejection>,
251}
252
253impl LearningRun {
254    fn new(
255        gate: BenchmarkGateReport,
256        trace_count: usize,
257        proposals: Vec<LearnedRuleProposal>,
258        rejections: Vec<LearningRejection>,
259    ) -> Self {
260        let fingerprint = format!(
261            "{}:{}:{}:{}:{}",
262            gate.suite_id,
263            gate.passed,
264            gate.failed,
265            trace_count,
266            proposals
267                .iter()
268                .map(|proposal| proposal.id.as_str())
269                .collect::<Vec<_>>()
270                .join(",")
271        );
272        Self {
273            id: stable_id("self_improvement_run", &fingerprint),
274            trace_count,
275            gate,
276            proposals,
277            rejections,
278        }
279    }
280
281    /// Adoptable learned rules after verification and benchmark gating.
282    #[must_use]
283    pub fn adoptable_rules(&self) -> Vec<&LearnedRuleProposal> {
284        self.proposals
285            .iter()
286            .filter(|proposal| proposal.adoption == LearnedRuleAdoption::Adoptable)
287            .collect()
288    }
289
290    /// Human-readable Links Notation summary of the learning run.
291    #[must_use]
292    pub fn links_notation(&self) -> String {
293        let mut out = String::from("self_improvement_run\n");
294        push_quoted_field(&mut out, "id", &self.id);
295        let _ = writeln!(out, "  trace_count \"{}\"", self.trace_count);
296        push_quoted_field(&mut out, "benchmark_suite", &self.gate.suite_id);
297        push_quoted_field(&mut out, "benchmark_runner", &self.gate.runner);
298        let _ = writeln!(out, "  benchmark_passed \"{}\"", self.gate.passed);
299        let _ = writeln!(out, "  benchmark_failed \"{}\"", self.gate.failed);
300        let _ = writeln!(
301            out,
302            "  benchmark_minimum_pass_count \"{}\"",
303            self.gate.minimum_pass_count
304        );
305        push_quoted_field(&mut out, "benchmark_status", self.gate.status_slug());
306        for proposal in &self.proposals {
307            out.push_str("  learned_rule\n");
308            push_quoted_nested_field(&mut out, "id", &proposal.id);
309            push_quoted_nested_field(&mut out, "trace", &proposal.trace_id);
310            push_quoted_nested_field(&mut out, "rule", &proposal.rule_id);
311            push_quoted_nested_field(&mut out, "base_task", &proposal.base_task);
312            push_quoted_nested_field(&mut out, "modifier", &proposal.modifier);
313            push_quoted_nested_field(&mut out, "resolved_task", &proposal.resolved_task);
314            push_quoted_nested_field(&mut out, "fixture", &proposal.fixture);
315            push_quoted_nested_field(&mut out, "adoption", proposal.adoption.slug());
316            push_quoted_nested_field(&mut out, "summary", &proposal.summary);
317            push_quoted_nested_field(&mut out, "seed_rule", &proposal.seed_rule_lino);
318        }
319        for rejection in &self.rejections {
320            out.push_str("  rejected_trace\n");
321            push_quoted_nested_field(&mut out, "trace", &rejection.trace_id);
322            push_quoted_nested_field(&mut out, "reason", &rejection.reason);
323        }
324        out.trim_end().to_owned()
325    }
326}
327
328fn propose_rule_from_trace(
329    trace: &UnknownTrace,
330    gate: &BenchmarkGateReport,
331) -> Result<LearnedRuleProposal, String> {
332    let candidate = trace
333        .events
334        .iter()
335        .rev()
336        .find(|event| event.kind == "rule_synthesis_candidate")
337        .ok_or_else(|| String::from("no rule_synthesis_candidate event"))?;
338    let verification = trace
339        .events
340        .iter()
341        .rev()
342        .find(|event| event.kind == "rule_verification")
343        .ok_or_else(|| String::from("no rule_verification event"))?;
344    let status = field_value(&verification.payload, "status").unwrap_or_default();
345    if status != "passed" {
346        return Err(format!("rule verification did not pass: {status}"));
347    }
348
349    let rule_id = require_field(&candidate.payload, "id")?;
350    let base_task = require_field(&candidate.payload, "base_task")?;
351    let modifier = require_field(&candidate.payload, "modifier")?;
352    let resolved_task = require_field(&candidate.payload, "resolved_task")?;
353    let fixture =
354        field_value(&verification.payload, "fixture").unwrap_or_else(|| String::from("unknown"));
355
356    for (name, value) in [
357        ("rule_id", rule_id.as_str()),
358        ("base_task", base_task.as_str()),
359        ("modifier", modifier.as_str()),
360        ("resolved_task", resolved_task.as_str()),
361    ] {
362        validate_slug(name, value)?;
363    }
364
365    let seed_rule_lino = learned_program_rule_lino(&rule_id, &base_task, &modifier, &resolved_task);
366    SubstitutionRuleSet::from_links_notation(&seed_rule_lino)
367        .map_err(|error| format!("learned rule does not parse: {error}"))?;
368
369    let adoption = if gate.permits_adoption() {
370        LearnedRuleAdoption::Adoptable
371    } else {
372        LearnedRuleAdoption::BlockedByBenchmark
373    };
374    let summary = format!(
375        "Learn `{modifier}` for `{base_task}` by rewriting to `{resolved_task}`; fixture `{fixture}` passed; benchmark `{}` is {} ({}/{}).",
376        gate.suite_id,
377        gate.status_slug(),
378        gate.passed,
379        gate.minimum_pass_count
380    );
381    let id = stable_id(
382        "learned_rule",
383        &format!(
384            "{}:{}:{}:{}:{}",
385            trace.id, rule_id, base_task, modifier, resolved_task
386        ),
387    );
388
389    Ok(LearnedRuleProposal {
390        id,
391        trace_id: trace.id.clone(),
392        rule_id,
393        base_task,
394        modifier,
395        resolved_task,
396        fixture,
397        summary,
398        seed_rule_lino,
399        adoption,
400    })
401}
402
403fn learned_program_rule_lino(
404    rule_id: &str,
405    base_task: &str,
406    modifier: &str,
407    resolved_task: &str,
408) -> String {
409    format!(
410        "substitution_rules\n  id \"learned_program_plan_rules\"\n  rule \"{rule_id}\"\n    order \"90\"\n    event \"learned\"\n    when \"request:modifier -> {modifier}\"\n    replace \"request:task -> {base_task}\"\n      with \"request:task -> {resolved_task}\""
411    )
412}
413
414fn require_field(block: &str, name: &str) -> Result<String, String> {
415    field_value(block, name).ok_or_else(|| format!("missing `{name}` in candidate"))
416}
417
418fn field_value(block: &str, name: &str) -> Option<String> {
419    for line in block.lines() {
420        let trimmed = line.trim();
421        let Some(rest) = trimmed.strip_prefix(name) else {
422            continue;
423        };
424        if rest.is_empty() {
425            continue;
426        }
427        if rest.starts_with(char::is_whitespace) {
428            return Some(unquote(rest.trim()));
429        }
430    }
431    None
432}
433
434fn validate_slug(name: &str, value: &str) -> Result<(), String> {
435    let valid = !value.is_empty()
436        && value
437            .chars()
438            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-'));
439    if valid {
440        Ok(())
441    } else {
442        Err(format!("invalid {name} `{value}`"))
443    }
444}
445
446#[derive(Debug)]
447struct ParsedRecord {
448    fields: Vec<(String, String)>,
449}
450
451impl ParsedRecord {
452    fn field(&self, name: &str) -> Option<&str> {
453        self.fields
454            .iter()
455            .find_map(|(field_name, value)| (field_name == name).then_some(value.as_str()))
456    }
457}
458
459fn parse_first_record(text: &str) -> Option<ParsedRecord> {
460    let block = text
461        .split("\n\n")
462        .map(str::trim)
463        .find(|record| !record.is_empty())?;
464    let fields = block
465        .lines()
466        .skip(1)
467        .filter_map(|line| {
468            let trimmed = line.trim();
469            let (name, raw) = trimmed.split_once(' ')?;
470            Some((name.to_owned(), unquote(raw.trim())))
471        })
472        .collect();
473    Some(ParsedRecord { fields })
474}
475
476fn unquote(value: &str) -> String {
477    let value = value
478        .strip_prefix('"')
479        .and_then(|inner| inner.strip_suffix('"'))
480        .unwrap_or(value);
481    let mut out = String::with_capacity(value.len());
482    let mut chars = value.chars();
483    while let Some(ch) = chars.next() {
484        if ch == '\\' {
485            match chars.next() {
486                Some('n') => out.push('\n'),
487                Some('"') => out.push('"'),
488                Some('\\') | None => out.push('\\'),
489                Some(other) => {
490                    out.push('\\');
491                    out.push(other);
492                }
493            }
494        } else {
495            out.push(ch);
496        }
497    }
498    out
499}
500
501fn push_quoted_field(out: &mut String, key: &str, value: &str) {
502    let _ = writeln!(out, "  {key} \"{}\"", quote(value));
503}
504
505fn push_quoted_nested_field(out: &mut String, key: &str, value: &str) {
506    let _ = writeln!(out, "    {key} \"{}\"", quote(value));
507}
508
509fn quote(value: &str) -> String {
510    value
511        .replace('\\', "\\\\")
512        .replace('"', "'")
513        .replace('\n', "\\n")
514        .replace('\r', "\\r")
515        .replace('\t', "\\t")
516}