Skip to main content

writerslogic_warm/core/agency/
self_modify.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Self-modification proposals with mandatory approval.
5//!
6//! The system can propose changes to its own rules, thresholds, or
7//! decomposition patterns. ALL proposals require explicit user approval
8//! before taking effect. Every proposal and its outcome is logged.
9
10use crate::core::sync::RwLock;
11
12/// A proposed self-modification.
13#[derive(Clone, Debug)]
14pub struct Proposal {
15    pub id: usize,
16    pub kind: ProposalKind,
17    pub reason: String,
18    pub status: ProposalStatus,
19}
20
21/// What kind of modification is proposed.
22#[derive(Clone, Debug)]
23pub enum ProposalKind {
24    /// Add a new composition rule.
25    AddRule {
26        name: String,
27        input_relations: Vec<String>,
28        output_relation: String,
29    },
30    /// Adjust a numeric threshold (e.g., fanout limit, IDF clip factor).
31    AdjustThreshold {
32        parameter: String,
33        current_value: f64,
34        proposed_value: f64,
35    },
36    /// Add a new decomposition pattern to the decomposer.
37    AddPattern { description: String },
38    /// Remove a stale or incorrect triple.
39    RemoveTriple {
40        subject: String,
41        relation: String,
42        object: String,
43    },
44}
45
46/// Lifecycle of a proposal.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub enum ProposalStatus {
49    Pending,
50    Approved,
51    Rejected,
52    Applied,
53}
54
55/// Manages self-modification proposals. Nothing is applied without approval.
56pub struct SelfModifier {
57    proposals: RwLock<Vec<Proposal>>,
58    audit_log: RwLock<Vec<AuditEntry>>,
59}
60
61/// Record of a proposal event.
62#[derive(Clone, Debug)]
63pub struct AuditEntry {
64    pub proposal_id: usize,
65    pub action: String,
66    pub timestamp_ms: u64,
67}
68
69fn now_ms() -> u64 {
70    std::time::SystemTime::now()
71        .duration_since(std::time::UNIX_EPOCH)
72        .unwrap_or_default()
73        .as_millis() as u64
74}
75
76impl Default for SelfModifier {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl SelfModifier {
83    pub fn new() -> Self {
84        Self {
85            proposals: RwLock::new(Vec::new()),
86            audit_log: RwLock::new(Vec::new()),
87        }
88    }
89
90    /// Submit a proposal. Returns the proposal ID.
91    pub fn propose(&self, kind: ProposalKind, reason: String) -> usize {
92        let id = {
93            let mut proposals = self.proposals.write();
94            let id = proposals.len();
95            proposals.push(Proposal {
96                id,
97                kind,
98                reason,
99                status: ProposalStatus::Pending,
100            });
101            id
102        };
103        self.log_action(id, "proposed");
104        id
105    }
106
107    /// Approve a pending proposal. Returns true if it was pending.
108    pub fn approve(&self, id: usize) -> bool {
109        let changed = {
110            let mut proposals = self.proposals.write();
111            if let Some(p) = proposals.get_mut(id) {
112                if p.status == ProposalStatus::Pending {
113                    p.status = ProposalStatus::Approved;
114                    true
115                } else {
116                    false
117                }
118            } else {
119                false
120            }
121        };
122        if changed {
123            self.log_action(id, "approved");
124        }
125        changed
126    }
127
128    /// Reject a pending proposal. Returns true if it was pending.
129    pub fn reject(&self, id: usize) -> bool {
130        let changed = {
131            let mut proposals = self.proposals.write();
132            if let Some(p) = proposals.get_mut(id) {
133                if p.status == ProposalStatus::Pending {
134                    p.status = ProposalStatus::Rejected;
135                    true
136                } else {
137                    false
138                }
139            } else {
140                false
141            }
142        };
143        if changed {
144            self.log_action(id, "rejected");
145        }
146        changed
147    }
148
149    /// Mark an approved proposal as applied. Returns true if it was approved.
150    pub fn mark_applied(&self, id: usize) -> bool {
151        let changed = {
152            let mut proposals = self.proposals.write();
153            if let Some(p) = proposals.get_mut(id) {
154                if p.status == ProposalStatus::Approved {
155                    p.status = ProposalStatus::Applied;
156                    true
157                } else {
158                    false
159                }
160            } else {
161                false
162            }
163        };
164        if changed {
165            self.log_action(id, "applied");
166        }
167        changed
168    }
169
170    fn log_action(&self, proposal_id: usize, action: &str) {
171        self.audit_log.write().push(AuditEntry {
172            proposal_id,
173            action: action.to_string(),
174            timestamp_ms: now_ms(),
175        });
176    }
177
178    pub fn pending_proposals(&self) -> Vec<Proposal> {
179        self.proposals
180            .read()
181            .iter()
182            .filter(|p| p.status == ProposalStatus::Pending)
183            .cloned()
184            .collect()
185    }
186
187    pub fn get(&self, id: usize) -> Option<Proposal> {
188        self.proposals.read().get(id).cloned()
189    }
190
191    pub fn proposal_count(&self) -> usize {
192        self.proposals.read().len()
193    }
194
195    pub fn pending_count(&self) -> usize {
196        self.proposals
197            .read()
198            .iter()
199            .filter(|p| p.status == ProposalStatus::Pending)
200            .count()
201    }
202
203    pub fn audit_entries(&self) -> Vec<AuditEntry> {
204        self.audit_log.read().clone()
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn test_propose_approve_apply() {
214        let sm = SelfModifier::new();
215        let id = sm.propose(
216            ProposalKind::AddRule {
217                name: "uncle".to_string(),
218                input_relations: vec!["parent".to_string(), "sibling".to_string()],
219                output_relation: "uncle".to_string(),
220            },
221            "Discovered uncle pattern in triples".to_string(),
222        );
223
224        assert_eq!(sm.pending_count(), 1);
225        assert!(sm.approve(id));
226        assert_eq!(sm.pending_count(), 0);
227        assert_eq!(sm.get(id).unwrap().status, ProposalStatus::Approved);
228
229        assert!(sm.mark_applied(id));
230        assert_eq!(sm.get(id).unwrap().status, ProposalStatus::Applied);
231    }
232
233    #[test]
234    fn test_reject() {
235        let sm = SelfModifier::new();
236        let id = sm.propose(
237            ProposalKind::AdjustThreshold {
238                parameter: "fanout_limit".to_string(),
239                current_value: 40.0,
240                proposed_value: 60.0,
241            },
242            "High fan-out queries are slow".to_string(),
243        );
244
245        assert!(sm.reject(id));
246        assert_eq!(sm.get(id).unwrap().status, ProposalStatus::Rejected);
247        assert!(!sm.approve(id)); // can't approve a rejected proposal
248    }
249
250    #[test]
251    fn test_cannot_apply_without_approval() {
252        let sm = SelfModifier::new();
253        let id = sm.propose(
254            ProposalKind::RemoveTriple {
255                subject: "wrong".to_string(),
256                relation: "is".to_string(),
257                object: "right".to_string(),
258            },
259            "Incorrect triple".to_string(),
260        );
261
262        assert!(!sm.mark_applied(id)); // still pending, not approved
263    }
264
265    #[test]
266    fn test_audit_trail() {
267        let sm = SelfModifier::new();
268        let id = sm.propose(
269            ProposalKind::AddPattern {
270                description: "passive voice pattern".to_string(),
271            },
272            "Improve decomposer".to_string(),
273        );
274        sm.approve(id);
275        sm.mark_applied(id);
276
277        let entries = sm.audit_entries();
278        assert_eq!(entries.len(), 3);
279        assert_eq!(entries[0].action, "proposed");
280        assert_eq!(entries[1].action, "approved");
281        assert_eq!(entries[2].action, "applied");
282    }
283
284    #[test]
285    fn test_multiple_proposals() {
286        let sm = SelfModifier::new();
287        sm.propose(
288            ProposalKind::AddRule {
289                name: "r1".to_string(),
290                input_relations: vec![],
291                output_relation: "r1".to_string(),
292            },
293            "reason 1".to_string(),
294        );
295        sm.propose(
296            ProposalKind::AddRule {
297                name: "r2".to_string(),
298                input_relations: vec![],
299                output_relation: "r2".to_string(),
300            },
301            "reason 2".to_string(),
302        );
303
304        assert_eq!(sm.proposal_count(), 2);
305        assert_eq!(sm.pending_count(), 2);
306
307        let pending = sm.pending_proposals();
308        assert_eq!(pending.len(), 2);
309    }
310
311    #[test]
312    fn test_empty() {
313        let sm = SelfModifier::new();
314        assert_eq!(sm.proposal_count(), 0);
315        assert_eq!(sm.pending_count(), 0);
316        assert!(sm.get(0).is_none());
317        assert!(sm.audit_entries().is_empty());
318    }
319}