Skip to main content

borg/learn/
regret.rs

1//! A regret: an earlier session's decision whose lines a later session fixed (notes/regress.rs).
2//! `borg learn` asks about it as about a correction, one model call, with its own opening and
3//! sections: the lesson is a question to ask at the decision point ("Before <doing X> in
4//! <situation>, has <risk Y> been considered?"). Reach, merging and superseding follow the
5//! correction prompt's rules unchanged.
6//!
7//! Everything here was redacted before it was written (notes.rs `redact`).
8
9use serde::{Deserialize, Serialize};
10
11use crate::notes::regress_signals::{REVERT, SUBJECT, TESTS, WORDS};
12
13/// `Episode::kind` of a regret.
14pub const KIND: &str = "regret";
15
16/// The first signal of a regret from a review finding (review_fix.rs): found and fixed inside the
17/// session, before any commit. `decision_task` is then the built task, `decision_proposal` the
18/// builder's account, `fix_subject` the finding and `diff` its detail.
19pub const REVIEW: &str = "review";
20
21/// Each excerpt in the prompt is cut to this many characters. Derived: an episode's excerpt bound
22/// (learn.rs `EXCERPT_CHARS`), so a regret's prompt keeps as much of each part as a correction's.
23pub const CAP: usize = super::EXCERPT_CHARS;
24
25/// The decision and the fix that changed its lines.
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Regret {
28    /// The decision's commit.
29    pub decision: String,
30    pub decision_session: String,
31    pub decision_task: String,
32    /// The end of the decision's last proposal.
33    pub decision_proposal: String,
34    /// The fix's commit.
35    pub fix: String,
36    pub fix_subject: String,
37    /// What the fix changed in the decision's files (`-`/`+` lines per file).
38    pub diff: String,
39    /// The fixing session's own words (prompts, the end of its last proposal).
40    #[serde(default, skip_serializing_if = "String::is_empty")]
41    pub fix_why: String,
42    pub signals: Vec<String>,
43    pub age_days: u32,
44    /// When the decision was made, epoch ms: the decision commit's committer time, or the review
45    /// finding's time. 0 when unknown (episodes from before it was kept): such a regret credits no lesson.
46    #[serde(default, skip_serializing_if = "is_zero")]
47    pub decision_ms: i64,
48}
49
50fn is_zero(v: &i64) -> bool {
51    *v == 0
52}
53
54/// How strongly a regret blames the decision, in (0, 1): `1 − 2^−g` over its independent grounds g: a
55/// revert, a fix-shaped subject, a red-then-green test run together with the engineer's words of a bug
56/// (regress_signals.rs `strong`); for a review regret the finding itself, and a verifier's confirmation.
57/// One ground 0.5, two 0.75, three 0.875; 0 for a regret that is not strong (never learned from anyway).
58/// A parameter-free count of evidence, not a tuning knob: the offline report breaks the credit down by g.
59#[must_use]
60pub fn strength(r: &Regret) -> f64 {
61    let has = |s: &str| r.signals.iter().any(|x| x == s);
62    let g = match is_review(r) {
63        true => 1 + u32::from(has("confirmed")),
64        false => u32::from(has(REVERT)) + u32::from(has(SUBJECT)) + u32::from(has(TESTS) && has(WORDS)),
65    };
66    match g {
67        0 => 0.0,
68        g => 1.0 - 0.5f64.powi(g as i32),
69    }
70}
71
72/// How a regret prompt opens (the correction prompt's reach and merge rules follow it).
73const HEAD: &str = "A coding agent made a decision in an earlier session (its task and reasoning below). {age} days later, \
74in another session, the same lines were changed by a fix ({signals}). Decide whether the fix reveals a reusable lesson about \
75the original decision: something the agent should have weighed at the moment it decided. A later change for an unrelated \
76reason that merely touched the same lines (a rename, a new feature, a dependency bump, a style change) is NOT a lesson.";
77
78/// How a review regret opens.
79const REVIEW_HEAD: &str = "A coding agent built a change (its task and its own account below). Before anything was committed, a \
80reviewer in the same session found the problem below ({signals}), and a fix step fixed it. Decide whether the finding reveals a \
81reusable lesson about the build: something the agent should have weighed while building. A typo, a style nit, or a one-off slip \
82with no risk that transfers to other work is NOT a lesson.";
83
84/// The fields a regret lesson is written in (the area line follows, shared with corrections).
85pub const FIELDS: &str = "If it is a lesson, write:
86- question: ONE question to ask at the decision point, in the form \"Before <choosing/doing X> in <kind of situation>, has \
87<the risk Y that the fix revealed> been considered?\"; general enough to transfer, keeping the technical substance;
88- situations: 1-3 lines, the decision's situations, each `<action> on/in <kind of state or component> [under <condition>]`, \
89without project/class names;
90- consequence: one line, what went wrong later, as the fix shows;";
91
92/// What a signal (`notes/regress_signals.rs`) says, for the model.
93fn said(signal: &str) -> &str {
94    match signal {
95        "revert" => "the fix reverts the decision",
96        "subject" => "the fix's commit subject says it fixes something",
97        "tests" => "a test failed, then passed in the fixing session",
98        "words" => "the engineer spoke of a bug in that session",
99        REVIEW => "a reviewer found it before any commit",
100        "confirmed" => "a verifier confirmed it",
101        "unchecked" => "no verifier checked it",
102        other => other,
103    }
104}
105
106/// The opening, with the regret's age and signals.
107#[must_use]
108pub fn head(r: &Regret) -> String {
109    let signals = if r.signals.is_empty() { "no stated reason".to_string() } else { r.signals.iter().map(|s| said(s)).collect::<Vec<_>>().join("; ") };
110    let head = if is_review(r) { REVIEW_HEAD } else { HEAD };
111    head.replace("{age}", &r.age_days.to_string()).replace("{signals}", &signals)
112}
113
114/// A regret from a review finding, not from a later commit.
115#[must_use]
116pub fn is_review(r: &Regret) -> bool {
117    r.signals.first().is_some_and(|s| s == REVIEW)
118}
119
120/// The correction prompt's reach and merge rules (learn.rs), worded for a regret.
121#[must_use]
122pub fn neutral(rules: &str) -> String {
123    rules.replace(", e.g. the engineer reversed an earlier correction", "").replace("this correction", "this lesson")
124}
125
126fn cut(s: &str) -> String {
127    s.chars().take(CAP).collect()
128}
129
130fn tail(s: &str) -> String {
131    let n = s.chars().count();
132    s.chars().skip(n.saturating_sub(CAP)).collect()
133}
134
135/// The decision, its reasoning, the fix (`fix_task`: the fixing session's task), why it was made
136/// and its change.
137#[must_use]
138pub fn sections(r: &Regret, fix_task: &str) -> String {
139    if is_review(r) {
140        return format!(
141            "## The task the agent built\n{}\n\n## The agent's account of its build (excerpt)\n{}\n\n## The review finding (fixed in the same session, before any commit)\n{}\n{}\n\n",
142            cut(&r.decision_task),
143            tail(&r.decision_proposal),
144            r.fix_subject,
145            cut(&r.diff)
146        );
147    }
148    let why = if r.fix_why.is_empty() { String::new() } else { format!("## Why it was fixed (the fixing session's words, excerpt)\n{}\n\n", tail(&r.fix_why)) };
149    format!(
150        "## The decision (task)\n{}\n\n## The decision's reasoning (excerpt)\n{}\n\n## The fix (subject, task)\n{}\n{}\n\n{why}## What the fix changed in the decision's files (excerpt)\n{}\n\n",
151        cut(&r.decision_task),
152        tail(&r.decision_proposal),
153        r.fix_subject,
154        cut(fix_task),
155        cut(&r.diff)
156    )
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn strength_counts_independent_grounds() {
165        let of = |signals: &[&str]| strength(&Regret { signals: signals.iter().map(|s| s.to_string()).collect(), ..Regret::default() });
166        assert_eq!(of(&["subject"]), 0.5);
167        assert_eq!(of(&["revert", "subject"]), 0.75);
168        assert_eq!(of(&["revert", "subject", "tests", "words"]), 0.875);
169        assert_eq!(of(&["tests"]), 0.0, "red-then-green alone is how a feature is built");
170        assert_eq!(of(&["tests", "words"]), 0.5);
171        assert_eq!(of(&[REVIEW, "unchecked"]), 0.5);
172        assert_eq!(of(&[REVIEW, "confirmed"]), 0.75);
173        assert_eq!(of(&[]), 0.0);
174    }
175
176    #[test]
177    fn a_review_regret_has_its_own_opening_and_sections() {
178        let r = Regret {
179            decision_task: "port the voice lane".into(),
180            decision_proposal: "Built it; all tests pass.".into(),
181            fix_subject: "A failed recommendation reaches the model as status ok".into(),
182            diff: "src/Rec.java: the error branch returns ok()".into(),
183            signals: vec![REVIEW.into(), "confirmed".into()],
184            ..Regret::default()
185        };
186        assert!(head(&r).starts_with("A coding agent built a change") && !head(&r).contains("days later"), "{}", head(&r));
187        assert!(head(&r).contains("(a reviewer found it before any commit; a verifier confirmed it)"), "{}", head(&r));
188        let s = sections(&r, "ignored");
189        for part in [
190            "## The task the agent built\nport the voice lane",
191            "Built it; all tests pass.",
192            "before any commit)\nA failed recommendation",
193            "error branch returns ok()",
194        ] {
195            assert!(s.contains(part), "{part}: {s}");
196        }
197        assert!(!s.contains("ignored") && !is_review(&Regret::default()));
198    }
199
200    #[test]
201    fn the_prompt_names_age_signals_and_both_sides() {
202        let r = Regret {
203            decision_task: "read the flag in b".into(),
204            decision_proposal: "No lock needed.".into(),
205            fix_subject: "fix: take the lock".into(),
206            diff: "src/lib.rs\n- fn b() { read_the_flag(); }".into(),
207            signals: vec!["subject".into(), "tests".into()],
208            age_days: 3,
209            ..Regret::default()
210        };
211        assert!(head(&r).starts_with("A coding agent made a decision") && head(&r).contains("3 days later"));
212        assert!(head(&r).contains("(the fix's commit subject says it fixes something; a test failed, then passed in the fixing session)"), "{}", head(&r));
213        assert!(head(&Regret::default()).contains("(no stated reason)"));
214        let s = sections(&r, "make the flag safe");
215        for part in ["## The decision (task)\nread the flag in b", "No lock needed.", "fix: take the lock\nmake the flag safe", "- fn b()"] {
216            assert!(s.contains(part), "{part}: {s}");
217        }
218        assert!(!s.contains("## Why it was fixed"));
219        let why = sections(&Regret { fix_why: "the sync test is flaky".into(), ..r.clone() }, "");
220        assert!(why.contains("## Why it was fixed (the fixing session's words, excerpt)\nthe sync test is flaky\n"), "{why}");
221        assert_eq!(
222            neutral("get this correction too? the opposite, e.g. the engineer reversed an earlier correction), x"),
223            "get this lesson too? the opposite), x"
224        );
225        let long = Regret { decision_proposal: format!("{}END", "x".repeat(2000)), ..Regret::default() };
226        assert!(sections(&long, "").contains("xEND\n") && !sections(&long, "").contains(&"x".repeat(1501)), "the proposal's end is kept");
227    }
228}