1use 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#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct UnknownTrace {
25 pub id: String,
27 pub prompt: String,
29 pub events: Vec<Event>,
31}
32
33impl UnknownTrace {
34 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct BenchmarkGateReport {
95 pub suite_id: String,
97 pub runner: String,
99 pub passed: usize,
101 pub failed: usize,
103 pub minimum_pass_count: usize,
105}
106
107impl BenchmarkGateReport {
108 #[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 #[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 #[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct LearnedRuleProposal {
189 pub id: String,
191 pub trace_id: String,
193 pub rule_id: String,
195 pub base_task: String,
197 pub modifier: String,
199 pub resolved_task: String,
201 pub fixture: String,
203 pub summary: String,
205 pub seed_rule_lino: String,
207 pub adoption: LearnedRuleAdoption,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum LearnedRuleAdoption {
214 Adoptable,
216 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#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct LearningRejection {
232 pub trace_id: String,
234 pub reason: String,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct LearningRun {
241 pub id: String,
243 pub trace_count: usize,
245 pub gate: BenchmarkGateReport,
247 pub proposals: Vec<LearnedRuleProposal>,
249 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 #[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 #[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}