formal_ai/change_request.rs
1//! User-requested self-change through the same human-gated repair loop (issue #558).
2//!
3//! Issue #558 ("Auto learning") asks that *"users must be able to ask for changes in
4//! the AI system through this mechanism"* (`R558-07`): a natural-language change
5//! request should flow through the *same* human-gated repair loop that the
6//! self-healing slices use — producing a requirement, a test, and a patch, offered as
7//! a reviewable pull request that only merges when the tests pass and the user
8//! accepts.
9//!
10//! This module composes that flow deterministically. A raw request plus the owned
11//! module it targets becomes a [`ChangeRequest`]: a normalised *requirement*, a
12//! proposed *test* name, and an ordered *patch plan* whose target module is grounded
13//! against the owned manifest ([`crate::self_source_links::owned_manifest`]), so a
14//! request can never target source the repository does not ship. The whole thing
15//! serialises to Links Notation — the reviewable pull request a human reads.
16//!
17//! Crucially it is **proposal-only and human-gated**, exactly like
18//! [`crate::self_healing`] and [`crate::learning_ledger`]: [`ChangeRequest::review`]
19//! reuses the same two acceptance conditions — a green [`BenchmarkGateReport`] *and*
20//! an explicit [`HumanApproval`] — and refuses every case that is not both, so no
21//! user request is ever applied automatically. Neural inference stays a NON-GOAL: the
22//! requirement, test, and patch plan are deterministic functions of the request and
23//! its grounded target, and the *patch* is a plan a human or Agent CLI executes, not
24//! generated code.
25
26use std::fmt::Write as _;
27
28use crate::engine::stable_id;
29use crate::learning_ledger::HumanApproval;
30use crate::self_improvement::BenchmarkGateReport;
31use crate::self_source_links::owned_manifest;
32
33/// A natural-language request to change Formal AI itself, turned into a structured,
34/// reviewable, human-gated proposal.
35///
36/// Every field is a deterministic function of the raw request and the owned module it
37/// targets, so the whole proposal — and its content id — is reproducible.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ChangeRequest {
40 /// Stable content-addressed id of the request.
41 pub id: String,
42 /// The raw natural-language request, verbatim.
43 pub request: String,
44 /// The owned `src/**/*.rs` module the change targets (grounded against the manifest).
45 pub target_module: String,
46 /// The manifest content id of the target module — provenance for the review.
47 pub target_content_id: String,
48 /// The requirement derived from the request — the "requirements" half of the loop.
49 pub derived_requirement: String,
50 /// The name of the test the change must add before it is reviewable.
51 pub proposed_test: String,
52 /// The ordered patch plan a human or Agent CLI executes — the reviewable "patch".
53 pub patch_plan: Vec<String>,
54}
55
56impl ChangeRequest {
57 /// Turn `request` into a reviewable change proposal targeting `target_module`.
58 ///
59 /// # Panics
60 ///
61 /// Panics if `target_module` is not an owned `src/**/*.rs` file, or if `request`
62 /// is empty once trimmed. Both are deliberate: a change request must target real,
63 /// content-addressed source (never a path the repository does not ship), and an
64 /// empty request has no requirement to derive.
65 #[must_use]
66 pub fn for_module(request: &str, target_module: &str) -> Self {
67 let trimmed = request.trim();
68 assert!(
69 !trimmed.is_empty(),
70 "a change request must carry a non-empty request"
71 );
72 let Some(digest) = owned_manifest()
73 .into_iter()
74 .find(|digest| digest.path == target_module)
75 else {
76 panic!("a change request targets a module that is not in the owned manifest: {target_module}")
77 };
78
79 let derived_requirement = derive_requirement(trimmed);
80 let proposed_test = derive_test_name(trimmed);
81 let patch_plan = patch_plan(target_module, &proposed_test, &derived_requirement);
82 let id = stable_id(
83 "change_request",
84 &format!("{trimmed}:{target_module}:{}", digest.content_id),
85 );
86 Self {
87 id,
88 request: trimmed.to_owned(),
89 target_module: target_module.to_owned(),
90 target_content_id: digest.content_id,
91 derived_requirement,
92 proposed_test,
93 patch_plan,
94 }
95 }
96
97 /// Whether applying this change stays a human decision. Always `true`: the request
98 /// is proposal-only by construction, mirroring the self-healing loop.
99 #[must_use]
100 pub const fn is_human_gated(&self) -> bool {
101 true
102 }
103
104 /// Review the request against the same repair-loop gate as the learning ledger.
105 ///
106 /// Succeeds only when the benchmark gate is green (*"when tests … accept"*) *and*
107 /// `approval` is granted (*"and the user accept\[s\]"*), returning the merged
108 /// [`AcceptedChange`]. Otherwise returns the [`ChangeRejected`] reason, so no user
109 /// request is ever applied without both the tests and the human accepting.
110 ///
111 /// # Errors
112 ///
113 /// Returns [`ChangeRejected::TestsNotGreen`] if the gate does not permit adoption,
114 /// or [`ChangeRejected::HumanDeclined`] if the human withheld approval.
115 pub fn review(
116 &self,
117 gate: &BenchmarkGateReport,
118 approval: &HumanApproval,
119 ) -> Result<AcceptedChange, ChangeRejected> {
120 if !gate.permits_adoption() {
121 return Err(ChangeRejected::TestsNotGreen);
122 }
123 if !approval.is_granted() {
124 return Err(ChangeRejected::HumanDeclined);
125 }
126 Ok(AcceptedChange {
127 change_id: self.id.clone(),
128 request: self.request.clone(),
129 target_module: self.target_module.clone(),
130 requirement: self.derived_requirement.clone(),
131 test: self.proposed_test.clone(),
132 benchmark_suite: gate.suite_id.clone(),
133 benchmark_passed: gate.passed,
134 reviewer: approval.reviewer().to_owned(),
135 })
136 }
137
138 /// A one-line human-readable summary of the proposed change.
139 #[must_use]
140 pub fn summary(&self) -> String {
141 format!(
142 "Change request `{}` maps onto {} (content id {}); it derives the requirement \"{}\", proposes test `{}`, and a {}-step patch plan — human-gated, reviewable as a pull request.",
143 self.id,
144 self.target_module,
145 self.target_content_id,
146 self.derived_requirement,
147 self.proposed_test,
148 self.patch_plan.len(),
149 )
150 }
151
152 /// Render the whole change request as Links Notation — the reviewable pull request
153 /// a human reads before any merge. Ends trimmed of trailing whitespace.
154 #[must_use]
155 pub fn links_notation(&self) -> String {
156 let mut out = String::from("change_request\n");
157 field(&mut out, "id", &self.id);
158 field(&mut out, "request", &self.request);
159 field(&mut out, "human_gated", "true");
160 field(&mut out, "target_module", &self.target_module);
161 field(&mut out, "target_content_id", &self.target_content_id);
162 field(&mut out, "derived_requirement", &self.derived_requirement);
163 field(&mut out, "proposed_test", &self.proposed_test);
164 out.push_str(" reviewable_pull_request\n");
165 for step in &self.patch_plan {
166 nested(&mut out, "step", step);
167 }
168 out.trim_end().to_owned()
169 }
170
171 /// A stable content id over the request's Links Notation.
172 #[must_use]
173 pub fn content_id(&self) -> String {
174 stable_id("change_request", &self.links_notation())
175 }
176}
177
178/// Why a [`ChangeRequest`] could not be merged.
179///
180/// Both variants are guardrails: a user-requested change stays proposal-only until
181/// *both* the tests and the human accept, mirroring [`crate::learning_ledger`].
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum ChangeRejected {
184 /// The benchmark gate did not pass — "tests" did not accept.
185 TestsNotGreen,
186 /// The human withheld approval — "the user" did not accept.
187 HumanDeclined,
188}
189
190impl ChangeRejected {
191 /// A stable, human-readable slug for the rejection reason.
192 #[must_use]
193 pub const fn slug(self) -> &'static str {
194 match self {
195 Self::TestsNotGreen => "tests_not_green",
196 Self::HumanDeclined => "human_declined",
197 }
198 }
199}
200
201/// A user-requested change that passed the tests and was approved — a merged pull
202/// request. Deterministic: every field comes from the request, gate, and approval.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct AcceptedChange {
205 /// The originating change-request id (provenance back to the request).
206 pub change_id: String,
207 /// The raw natural-language request that was fulfilled.
208 pub request: String,
209 /// The owned module the accepted change targets.
210 pub target_module: String,
211 /// The requirement the change satisfies.
212 pub requirement: String,
213 /// The test that pinned the requested behaviour.
214 pub test: String,
215 /// The benchmark suite that gated acceptance.
216 pub benchmark_suite: String,
217 /// Passing case count from the gate run that green-lit the merge.
218 pub benchmark_passed: usize,
219 /// The human who approved the merge.
220 pub reviewer: String,
221}
222
223impl AcceptedChange {
224 /// A one-line human-readable summary of the accepted change.
225 #[must_use]
226 pub fn summary(&self) -> String {
227 format!(
228 "Accepted change `{}` for request \"{}\": requirement \"{}\", test `{}`, mapped onto {}, gated by {} passing case(s), approved by {}.",
229 self.change_id,
230 self.request,
231 self.requirement,
232 self.test,
233 self.target_module,
234 self.benchmark_passed,
235 self.reviewer,
236 )
237 }
238}
239
240/// Build the canonical, fully-worked change request.
241///
242/// A concrete user request ("let users ask Formal AI to reverse-sort program output")
243/// targeting the deterministic planner — the module that would route the new
244/// capability. Deterministic and self-contained — used by the agentic recipe, the
245/// example, and the tests.
246#[must_use]
247pub fn canonical_change_request() -> ChangeRequest {
248 ChangeRequest::for_module(
249 "Please add a new capability to Formal AI: let users ask it to reverse-sort a program's output.",
250 "src/agentic_coding/planner.rs",
251 )
252}
253
254/// Derive a requirement statement from a raw request.
255///
256/// Strips leading politeness, trailing punctuation, and lower-cases the first word, so
257/// "Please add X." becomes "The system must add X." — a deterministic transform, not
258/// paraphrase.
259fn derive_requirement(request: &str) -> String {
260 let mut core = collapse_whitespace(request);
261 for prefix in [
262 "please ",
263 "can you please ",
264 "can you ",
265 "could you please ",
266 "could you ",
267 "i want you to ",
268 "i would like you to ",
269 "i want ",
270 "i would like ",
271 "we need to ",
272 "we need ",
273 ] {
274 if let Some(stripped) = core.to_lowercase().strip_prefix(prefix) {
275 core = core[core.len() - stripped.len()..].to_owned();
276 break;
277 }
278 }
279 let core = core.trim().trim_end_matches(['.', '!', '?']).trim();
280 format!("The system must {}.", decapitalize(core))
281}
282
283/// Derive a `snake_case` test name from a raw request.
284fn derive_test_name(request: &str) -> String {
285 let mut slug = String::new();
286 let mut last_was_sep = true;
287 for ch in request.chars() {
288 if ch.is_ascii_alphanumeric() {
289 slug.push(ch.to_ascii_lowercase());
290 last_was_sep = false;
291 } else if !last_was_sep {
292 slug.push('_');
293 last_was_sep = true;
294 }
295 }
296 // Keep the name readable: at most the first eight words of the request.
297 let words: Vec<&str> = slug.trim_matches('_').split('_').take(8).collect();
298 format!("user_requested_change_{}", words.join("_"))
299}
300
301/// The fixed, ordered patch plan a human or Agent CLI executes to fulfil the request.
302///
303/// It is a *plan* (requirement → failing test → grounded edit → green gate → reviewed
304/// merge), not generated code — honouring the NON-GOAL of neural inference while still
305/// producing the "requirements, tests, patches, and a reviewable PR" the issue asks
306/// for.
307fn patch_plan(target_module: &str, test: &str, requirement: &str) -> Vec<String> {
308 vec![
309 format!("Record the derived requirement for review: {requirement}"),
310 format!("Add the failing test `{test}` that pins the requested behaviour"),
311 format!("Edit the grounded target module `{target_module}` until the test passes"),
312 "Run the benchmark gate; only a green run is reviewable".to_owned(),
313 "Open a reviewable pull request; a human approves before it merges".to_owned(),
314 ]
315}
316
317fn collapse_whitespace(value: &str) -> String {
318 value.split_whitespace().collect::<Vec<_>>().join(" ")
319}
320
321fn decapitalize(value: &str) -> String {
322 let mut chars = value.chars();
323 chars.next().map_or_else(String::new, |first| {
324 first.to_lowercase().collect::<String>() + chars.as_str()
325 })
326}
327
328fn field(out: &mut String, key: &str, value: &str) {
329 let _ = writeln!(out, " {key} \"{}\"", quote(value));
330}
331
332fn nested(out: &mut String, key: &str, value: &str) {
333 let _ = writeln!(out, " {key} \"{}\"", quote(value));
334}
335
336fn quote(value: &str) -> String {
337 value
338 .replace('\\', "\\\\")
339 .replace('"', "'")
340 .replace('\n', "\\n")
341 .replace('\r', "\\r")
342 .replace('\t', "\\t")
343}