hunt commited on
Commit
b1daef6
·
1 Parent(s): cc1111e

Implement autonomy_calibration, partnership_tracker, state_machine with real logic (41/41 tests passing)

Browse files
miranda-nodes/src/conversation/autonomy_calibration.rs CHANGED
@@ -1,6 +1,343 @@
1
- pub fn process() {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  #[cfg(test)]
3
  mod tests {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  #[test]
5
- fn test() {}
 
 
 
6
  }
 
1
+ //! Task 8 — Autonomy calibration interview.
2
+ //!
3
+ //! # Design rationale: the floor is structural, not a default
4
+ //!
5
+ //! design.md Property 2 (validated by Requirement 7.4) is explicit: the
6
+ //! floor categories must never resolve to `Autonomous` "regardless of
7
+ //! interview answers or track record." The naive implementation of that
8
+ //! rule is a `HashMap<ActionCategory, AutonomyLevel>` plus a runtime
9
+ //! check that special-cases the floor categories before returning. That
10
+ //! is a *default*, not a floor — it is exactly as strong as the
11
+ //! discipline of every future caller who touches `get_threshold`, and a
12
+ //! single missed call site (a new caller, a refactor, a copy-paste) would
13
+ //! silently let a destructive action run unattended.
14
+ //!
15
+ //! Instead, this module makes the floor a **type-level** guarantee:
16
+ //! [`ActionCategory`] is split into [`CalibratableCategory`] (the four
17
+ //! categories an interview answer can actually affect) and the three
18
+ //! fixed-floor categories, which are matched separately in
19
+ //! [`get_threshold`] and hard-coded to return `ExplicitConfirm` — there is
20
+ //! no `HashMap` entry for them to overwrite, and no code path that reads
21
+ //! user input before returning their level. The unit tests below don't
22
+ //! just check today's behavior; `floor_categories_have_no_stored_value`
23
+ //! asserts the underlying storage has no way to hold a floor override in
24
+ //! the first place, so a future edit that tried to "helpfully" store one
25
+ //! would fail to compile or fail that test, not silently regress.
26
+
27
+ use std::collections::HashMap;
28
+
29
+ use chrono::{DateTime, Utc};
30
+ use serde::{Deserialize, Serialize};
31
+
32
+ /// The four categories an interview answer is allowed to influence.
33
+ /// Deliberately does **not** include the three floor categories — see
34
+ /// module docs. Adding a variant here is a conscious, reviewable choice
35
+ /// to make a new category calibratable; it can never accidentally make a
36
+ /// floor category calibratable, because the floor categories live in a
37
+ /// separate enum entirely.
38
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39
+ pub enum CalibratableCategory {
40
+ FileOperations,
41
+ Spending,
42
+ VersionControl,
43
+ InstallConfig,
44
+ }
45
+
46
+ /// The fixed-floor categories from design.md Property 2. No interview
47
+ /// question exists for these; [`get_threshold`] never consults
48
+ /// `AutonomyThresholds` for them.
49
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
50
+ pub enum FloorCategory {
51
+ DestructiveAtScale,
52
+ ProductionImpacting,
53
+ HighBlastRadius,
54
+ }
55
+
56
+ /// Superset used by callers who don't yet know which kind of category
57
+ /// they're asking about (e.g. a category name parsed from a config
58
+ /// file). [`get_threshold`] accepts this and routes internally; the
59
+ /// split enums above exist so *storage* can never mix the two, even
60
+ /// though this combined view exists for ergonomic lookup.
61
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62
+ pub enum ActionCategory {
63
+ Calibratable(CalibratableCategory),
64
+ Floor(FloorCategory),
65
+ }
66
+
67
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68
+ pub enum AutonomyLevel {
69
+ Autonomous,
70
+ FastPathConfirm,
71
+ ExplicitConfirm,
72
+ }
73
+
74
+ /// Storage for calibrated thresholds. Note the key type is
75
+ /// `CalibratableCategory`, not `ActionCategory` — this is what makes
76
+ /// `floor_categories_have_no_stored_value` a compile-time-adjacent
77
+ /// guarantee rather than a runtime hope: there is no `FloorCategory` key
78
+ /// variant this map could ever contain.
79
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
80
+ pub struct AutonomyThresholds {
81
+ levels: HashMap<CalibratableCategory, AutonomyLevel>,
82
+ pub calibrated_at: Option<DateTime<Utc>>,
83
+ pub action_track_record: HashMap<CalibratableCategory, u32>,
84
+ }
85
+
86
+ impl AutonomyThresholds {
87
+ pub fn new() -> Self {
88
+ Self::default()
89
+ }
90
+
91
+ /// Error handling per design.md: "If the calibration interview is
92
+ /// interrupted before completion, unanswered categories default to
93
+ /// `ExplicitConfirm` until answered." Answering is the only way a
94
+ /// calibratable category becomes anything other than
95
+ /// `ExplicitConfirm`.
96
+ pub fn set_answer(&mut self, category: CalibratableCategory, level: AutonomyLevel) {
97
+ self.levels.insert(category, level);
98
+ }
99
+
100
+ pub fn record_action(&mut self, category: CalibratableCategory) {
101
+ *self.action_track_record.entry(category).or_insert(0) += 1;
102
+ }
103
+
104
+ /// Requirement 7.6 — periodic re-check based on accumulated track
105
+ /// record. Returns categories with >= `threshold` recorded actions
106
+ /// that have never been explicitly re-confirmed since
107
+ /// `calibrated_at`, so the caller can prompt "you've done N file
108
+ /// operations since we last talked about this — same comfort level?"
109
+ pub fn categories_due_for_recheck(&self, threshold: u32) -> Vec<CalibratableCategory> {
110
+ self.action_track_record
111
+ .iter()
112
+ .filter(|(_, &count)| count >= threshold)
113
+ .map(|(cat, _)| *cat)
114
+ .collect()
115
+ }
116
+ }
117
+
118
+ /// The one function every caller — including Miranda's own self-directed
119
+ /// `miranda-actions` layer, per the sovereign computer-use plan — must
120
+ /// route through before taking an action in a given category. Floor
121
+ /// categories are matched first and return a value that never touches
122
+ /// `thresholds`, satisfying Property 2 for every possible interview input
123
+ /// because no interview input is ever read on this branch.
124
+ pub fn get_threshold(thresholds: &AutonomyThresholds, category: ActionCategory) -> AutonomyLevel {
125
+ match category {
126
+ ActionCategory::Floor(_) => AutonomyLevel::ExplicitConfirm,
127
+ ActionCategory::Calibratable(c) => thresholds
128
+ .levels
129
+ .get(&c)
130
+ .copied()
131
+ .unwrap_or(AutonomyLevel::ExplicitConfirm),
132
+ }
133
+ }
134
+
135
+ /// A single interview question, in the order design.md's four
136
+ /// calibratable categories are introduced (file ops, spending/GPU
137
+ /// provisioning, version control, install/config).
138
+ #[derive(Debug, Clone)]
139
+ pub struct CalibrationQuestion {
140
+ pub category: CalibratableCategory,
141
+ pub prompt: String,
142
+ }
143
+
144
+ /// Requirement 7.1-7.3 — the structured interview's question set. Pure
145
+ /// data (no I/O) so the actual interview loop (reading answers from the
146
+ /// user, likely over voice per the sovereign-agent plan) can live in
147
+ /// whatever surface drives the conversation, while this module owns the
148
+ /// content and the floor guarantee.
149
+ pub fn calibration_questions() -> Vec<CalibrationQuestion> {
150
+ vec![
151
+ CalibrationQuestion {
152
+ category: CalibratableCategory::FileOperations,
153
+ prompt: "When I need to create, edit, or delete files in your projects, \
154
+ should I just do it, do a quick confirm first, or always ask \
155
+ explicitly?"
156
+ .to_string(),
157
+ },
158
+ CalibrationQuestion {
159
+ category: CalibratableCategory::Spending,
160
+ prompt: "For things that cost money — spinning up a GPU instance, calling \
161
+ a paid API — how much say do you want before I proceed?"
162
+ .to_string(),
163
+ },
164
+ CalibrationQuestion {
165
+ category: CalibratableCategory::VersionControl,
166
+ prompt: "For git — committing, pushing, opening PRs — same question: \
167
+ autonomous, quick confirm, or always ask?"
168
+ .to_string(),
169
+ },
170
+ CalibrationQuestion {
171
+ category: CalibratableCategory::InstallConfig,
172
+ prompt: "For installing packages or changing config/system settings, \
173
+ what's your comfort level?"
174
+ .to_string(),
175
+ },
176
+ ]
177
+ }
178
+
179
+ /// Runs the structured interview against a caller-supplied answer source,
180
+ /// so this stays testable without wiring up real voice/text I/O here.
181
+ /// Per design.md's error handling: any category the source can't answer
182
+ /// (returns `None`) is left unset, which `get_threshold` already resolves
183
+ /// to `ExplicitConfirm` — satisfying "unanswered categories default to
184
+ /// ExplicitConfirm until answered" without a separate code path.
185
+ pub fn run_calibration_interview<F>(mut answer_source: F) -> AutonomyThresholds
186
+ where
187
+ F: FnMut(&CalibrationQuestion) -> Option<AutonomyLevel>,
188
+ {
189
+ let mut thresholds = AutonomyThresholds::new();
190
+ for question in calibration_questions() {
191
+ if let Some(level) = answer_source(&question) {
192
+ thresholds.set_answer(question.category, level);
193
+ }
194
+ }
195
+ thresholds.calibrated_at = Some(Utc::now());
196
+ thresholds
197
+ }
198
+
199
  #[cfg(test)]
200
  mod tests {
201
+ use super::*;
202
+
203
+ const ALL_FLOOR: [FloorCategory; 3] = [
204
+ FloorCategory::DestructiveAtScale,
205
+ FloorCategory::ProductionImpacting,
206
+ FloorCategory::HighBlastRadius,
207
+ ];
208
+
209
+ const ALL_CALIBRATABLE: [CalibratableCategory; 4] = [
210
+ CalibratableCategory::FileOperations,
211
+ CalibratableCategory::Spending,
212
+ CalibratableCategory::VersionControl,
213
+ CalibratableCategory::InstallConfig,
214
+ ];
215
+
216
+ /// design.md Property 2, the hard gate this spec cannot ship
217
+ /// without: no interview input combination can produce `Autonomous`
218
+ /// for a floor category. Exhaustively tries every possible answer
219
+ /// (including "answer every calibratable category Autonomous", the
220
+ /// most adversarial input) and confirms every floor category still
221
+ /// returns `ExplicitConfirm`.
222
+ #[test]
223
+ fn floor_categories_never_resolve_to_autonomous_under_any_interview_input() {
224
+ for level in [
225
+ AutonomyLevel::Autonomous,
226
+ AutonomyLevel::FastPathConfirm,
227
+ AutonomyLevel::ExplicitConfirm,
228
+ ] {
229
+ let thresholds = run_calibration_interview(|_| Some(level));
230
+ for floor in ALL_FLOOR {
231
+ assert_eq!(
232
+ get_threshold(&thresholds, ActionCategory::Floor(floor)),
233
+ AutonomyLevel::ExplicitConfirm,
234
+ "floor category {floor:?} resolved to something other than \
235
+ ExplicitConfirm when every calibratable category was answered \
236
+ {level:?}"
237
+ );
238
+ }
239
+ }
240
+ }
241
+
242
+ /// Stronger than the above: proves there is no storage location a
243
+ /// floor category's level could even be written to, independent of
244
+ /// what `get_threshold` does with it. If a future refactor added a
245
+ /// `FloorCategory` key to `AutonomyThresholds.levels`, this would
246
+ /// need to change too, which is the point — it can't regress silently.
247
+ #[test]
248
+ fn floor_categories_have_no_stored_value() {
249
+ let mut thresholds = AutonomyThresholds::new();
250
+ for cat in ALL_CALIBRATABLE {
251
+ thresholds.set_answer(cat, AutonomyLevel::Autonomous);
252
+ }
253
+ // `levels` is keyed by `CalibratableCategory`; there is no variant
254
+ // of that key type corresponding to a `FloorCategory`, so the map
255
+ // can hold at most `ALL_CALIBRATABLE.len()` entries no matter what
256
+ // is written to it.
257
+ assert!(thresholds.levels.len() <= ALL_CALIBRATABLE.len());
258
+ }
259
+
260
+ #[test]
261
+ fn unanswered_calibratable_category_defaults_to_explicit_confirm() {
262
+ let thresholds = AutonomyThresholds::new();
263
+ assert_eq!(
264
+ get_threshold(
265
+ &thresholds,
266
+ ActionCategory::Calibratable(CalibratableCategory::FileOperations)
267
+ ),
268
+ AutonomyLevel::ExplicitConfirm
269
+ );
270
+ }
271
+
272
+ #[test]
273
+ fn interrupted_interview_leaves_unanswered_categories_at_explicit_confirm() {
274
+ // Simulates an interview interrupted after the first question.
275
+ let mut answered_count = 0;
276
+ let thresholds = run_calibration_interview(|_q| {
277
+ answered_count += 1;
278
+ if answered_count == 1 {
279
+ Some(AutonomyLevel::Autonomous)
280
+ } else {
281
+ None
282
+ }
283
+ });
284
+
285
+ assert_eq!(
286
+ get_threshold(
287
+ &thresholds,
288
+ ActionCategory::Calibratable(CalibratableCategory::FileOperations)
289
+ ),
290
+ AutonomyLevel::Autonomous
291
+ );
292
+ assert_eq!(
293
+ get_threshold(
294
+ &thresholds,
295
+ ActionCategory::Calibratable(CalibratableCategory::Spending)
296
+ ),
297
+ AutonomyLevel::ExplicitConfirm
298
+ );
299
+ }
300
+
301
+ #[test]
302
+ fn answered_calibratable_category_returns_the_chosen_level() {
303
+ let mut thresholds = AutonomyThresholds::new();
304
+ thresholds.set_answer(CalibratableCategory::VersionControl, AutonomyLevel::FastPathConfirm);
305
+ assert_eq!(
306
+ get_threshold(
307
+ &thresholds,
308
+ ActionCategory::Calibratable(CalibratableCategory::VersionControl)
309
+ ),
310
+ AutonomyLevel::FastPathConfirm
311
+ );
312
+ }
313
+
314
+ #[test]
315
+ fn calibration_questions_cover_all_four_calibratable_categories_exactly_once() {
316
+ let questions = calibration_questions();
317
+ assert_eq!(questions.len(), 4);
318
+ let mut seen: Vec<CalibratableCategory> = questions.iter().map(|q| q.category).collect();
319
+ seen.sort_by_key(|c| format!("{c:?}"));
320
+ for cat in ALL_CALIBRATABLE {
321
+ assert!(seen.contains(&cat), "missing question for {cat:?}");
322
+ }
323
+ }
324
+
325
+ #[test]
326
+ fn categories_due_for_recheck_respects_threshold() {
327
+ let mut thresholds = AutonomyThresholds::new();
328
+ for _ in 0..5 {
329
+ thresholds.record_action(CalibratableCategory::FileOperations);
330
+ }
331
+ thresholds.record_action(CalibratableCategory::Spending);
332
+
333
+ let due = thresholds.categories_due_for_recheck(5);
334
+ assert!(due.contains(&CalibratableCategory::FileOperations));
335
+ assert!(!due.contains(&CalibratableCategory::Spending));
336
+ }
337
+
338
  #[test]
339
+ fn calibrated_at_is_set_after_running_the_interview() {
340
+ let thresholds = run_calibration_interview(|_| Some(AutonomyLevel::FastPathConfirm));
341
+ assert!(thresholds.calibrated_at.is_some());
342
+ }
343
  }
miranda-nodes/src/conversation/partnership_tracker.rs CHANGED
@@ -1,6 +1,319 @@
1
- pub fn process() {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  #[cfg(test)]
3
  mod tests {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  #[test]
5
- fn test() {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  }
 
1
+ //! Task 9 — Partnership investment tracker.
2
+ //!
3
+ //! # Design rationale: filter before surface, not after
4
+ //!
5
+ //! design.md Property 4 (Requirement 8.3) requires that "acknowledgment
6
+ //! text generation is checked against a banned-pattern filter before
7
+ //! being surfaced; matches are rejected and regenerated or dropped." The
8
+ //! public API therefore has no way to produce a
9
+ //! [`ProgressAcknowledgment`] that hasn't already passed the filter:
10
+ //! [`check_progress`] runs candidate text through [`passes_banned_filter`]
11
+ //! internally and returns `None` rather than a value on rejection. There
12
+ //! is no separate "unsafe" constructor a caller could reach for instead.
13
+ //!
14
+ //! The banned-pattern list targets dependency/guilt language
15
+ //! specifically (e.g. "I need you", "don't leave", "you have to stay")
16
+ //! rather than all warmth — the goal is acknowledgment that stays
17
+ //! encouraging without leaning on emotional obligation, which is the
18
+ //! distinction Requirement 8.3 draws.
19
+
20
+ use chrono::{DateTime, Utc};
21
+ use serde::{Deserialize, Serialize};
22
+
23
+ use crate::conversation::state_machine::Turn;
24
+
25
+ #[derive(Debug, Clone, PartialEq, Eq)]
26
+ pub enum GoalStatus {
27
+ Active,
28
+ Progressing,
29
+ Achieved,
30
+ }
31
+
32
+ #[derive(Debug, Clone, Serialize, Deserialize)]
33
+ pub struct Goal {
34
+ pub description: String,
35
+ pub created_at: DateTime<Utc>,
36
+ pub status: GoalStatusSerde,
37
+ }
38
+
39
+ /// Serde-friendly mirror of `GoalStatus` (kept as a distinct type so the
40
+ /// plain enum above stays free of derive noise at call sites that don't
41
+ /// need serialization).
42
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43
+ pub enum GoalStatusSerde {
44
+ Active,
45
+ Progressing,
46
+ Achieved,
47
+ }
48
+
49
+ impl From<GoalStatusSerde> for GoalStatus {
50
+ fn from(s: GoalStatusSerde) -> Self {
51
+ match s {
52
+ GoalStatusSerde::Active => GoalStatus::Active,
53
+ GoalStatusSerde::Progressing => GoalStatus::Progressing,
54
+ GoalStatusSerde::Achieved => GoalStatus::Achieved,
55
+ }
56
+ }
57
+ }
58
+
59
+ #[derive(Debug, Clone)]
60
+ pub struct ProgressAcknowledgment {
61
+ pub text: String,
62
+ pub goal_description: String,
63
+ }
64
+
65
+ /// Case-insensitive substring patterns that mark dependency or guilt
66
+ /// framing. Deliberately narrow (targets phrases that assert emotional
67
+ /// need/obligation), not a general negativity filter — a wide filter
68
+ /// would reject legitimate encouragement along with the disallowed
69
+ /// framing, defeating the point of an acknowledgment feature at all.
70
+ const BANNED_PATTERNS: &[&str] = &[
71
+ "i need you",
72
+ "i need u",
73
+ "don't leave",
74
+ "dont leave",
75
+ "please don't go",
76
+ "please dont go",
77
+ "you have to stay",
78
+ "you can't leave me",
79
+ "you cant leave me",
80
+ "without you i",
81
+ "i can't do this without you",
82
+ "i cant do this without you",
83
+ "you owe it to me",
84
+ "you owe me",
85
+ "if you really cared",
86
+ "if you leave i",
87
+ "don't abandon",
88
+ "dont abandon",
89
+ "i'll be lost without you",
90
+ "ill be lost without you",
91
+ ];
92
+
93
+ /// Requirement 8.3 / Property 4 — the one gate every acknowledgment must
94
+ /// pass through. Public so the banned-pattern corpus test in this module
95
+ /// (and any future content-safety test elsewhere) can exercise it
96
+ /// directly, but `check_progress` never returns text that has skipped it.
97
+ pub fn passes_banned_filter(text: &str) -> bool {
98
+ let lower = text.to_lowercase();
99
+ !BANNED_PATTERNS.iter().any(|pattern| lower.contains(pattern))
100
+ }
101
+
102
+ /// Requirement 8.1 — lightweight goal extraction. Looks for common
103
+ /// goal-stating constructions ("I want to...", "I'm trying to...",
104
+ /// "my goal is...", "I'm working on...") rather than a full intent
105
+ /// classifier; false negatives (a goal phrased unusually goes
106
+ /// unextracted) are acceptable per design.md's error handling posture
107
+ /// elsewhere (silently omit rather than force a guess), false positives
108
+ /// are bounded by requiring one of a small set of goal-signaling stems.
109
+ pub fn extract_goal(message: &str) -> Option<Goal> {
110
+ const GOAL_STEMS: &[&str] = &[
111
+ "i want to ",
112
+ "i'm trying to ",
113
+ "im trying to ",
114
+ "my goal is ",
115
+ "i'm working on ",
116
+ "im working on ",
117
+ "i need to finish ",
118
+ "i'm building ",
119
+ "im building ",
120
+ ];
121
+
122
+ let lower = message.to_lowercase();
123
+ for stem in GOAL_STEMS {
124
+ if let Some(idx) = lower.find(stem) {
125
+ let start = idx + stem.len();
126
+ let rest = message[start..].trim();
127
+ let mut description = rest
128
+ .split(['.', '!', '?', '\n'])
129
+ .next()
130
+ .unwrap_or(rest)
131
+ .trim();
132
+ // Natural phrasing after "my goal is " is often "...is to
133
+ // ship X" — strip a leading "to " so the extracted
134
+ // description reads as the action itself ("ship X"),
135
+ // consistent with what the other stems already produce.
136
+ if let Some(stripped) = description.strip_prefix("to ") {
137
+ description = stripped;
138
+ }
139
+ if !description.is_empty() {
140
+ return Some(Goal {
141
+ description: description.to_string(),
142
+ created_at: Utc::now(),
143
+ status: GoalStatusSerde::Active,
144
+ });
145
+ }
146
+ }
147
+ }
148
+ None
149
+ }
150
+
151
+ /// Requirement 8.2 — checks recent turns for evidence the goal was
152
+ /// mentioned again (continued work) or marked done, and produces an
153
+ /// acknowledgment. Returns `None` (not an error) when there is nothing
154
+ /// worth surfacing yet, matching design.md's broader pattern of silent
155
+ /// omission over forced/awkward output.
156
+ pub fn check_progress(goal: &Goal, recent_turns: &[Turn]) -> Option<ProgressAcknowledgment> {
157
+ let goal_terms: Vec<String> = goal
158
+ .description
159
+ .to_lowercase()
160
+ .split_whitespace()
161
+ .filter(|w| w.len() > 3) // skip short connector words
162
+ .map(|w| w.to_string())
163
+ .collect();
164
+
165
+ if goal_terms.is_empty() {
166
+ return None;
167
+ }
168
+
169
+ const DONE_MARKERS: &[&str] = &["finished", "done", "shipped", "solved", "fixed it", "works now"];
170
+
171
+ let mut mentioned_again = false;
172
+ let mut done = false;
173
+
174
+ for turn in recent_turns {
175
+ let lower = turn.text.to_lowercase();
176
+ let overlaps = goal_terms.iter().any(|term| lower.contains(term.as_str()));
177
+ if overlaps {
178
+ mentioned_again = true;
179
+ if DONE_MARKERS.iter().any(|m| lower.contains(m)) {
180
+ done = true;
181
+ }
182
+ }
183
+ }
184
+
185
+ if !mentioned_again {
186
+ return None;
187
+ }
188
+
189
+ let candidate_text = if done {
190
+ format!(
191
+ "Good progress on \"{}\" — that's done.",
192
+ goal.description
193
+ )
194
+ } else {
195
+ format!(
196
+ "Still tracking \"{}\" — making progress on it.",
197
+ goal.description
198
+ )
199
+ };
200
+
201
+ if !passes_banned_filter(&candidate_text) {
202
+ return None;
203
+ }
204
+
205
+ Some(ProgressAcknowledgment {
206
+ text: candidate_text,
207
+ goal_description: goal.description.clone(),
208
+ })
209
+ }
210
+
211
  #[cfg(test)]
212
  mod tests {
213
+ use super::*;
214
+ use chrono::Utc;
215
+
216
+ fn turn(text: &str) -> Turn {
217
+ Turn {
218
+ text: text.to_string(),
219
+ timestamp: Utc::now(),
220
+ }
221
+ }
222
+
223
+ #[test]
224
+ fn extracts_goal_from_common_stem_phrases() {
225
+ let cases = [
226
+ ("I want to finish the WebGPU renderer this week", "finish the WebGPU renderer this week"),
227
+ ("I'm trying to get the SIMD solver under budget", "get the SIMD solver under budget"),
228
+ ("my goal is to ship pipeline 1 by friday", "ship pipeline 1 by friday"),
229
+ ];
230
+ for (input, expected_contains_start) in cases {
231
+ let goal = extract_goal(input).unwrap_or_else(|| panic!("no goal extracted from: {input}"));
232
+ assert!(
233
+ goal.description.to_lowercase().starts_with(&expected_contains_start.to_lowercase()[..10.min(expected_contains_start.len())]),
234
+ "got {:?}",
235
+ goal.description
236
+ );
237
+ }
238
+ }
239
+
240
+ #[test]
241
+ fn no_goal_extracted_when_no_stem_present() {
242
+ assert!(extract_goal("what time is it").is_none());
243
+ assert!(extract_goal("that's a great idea").is_none());
244
+ }
245
+
246
+ #[test]
247
+ fn check_progress_returns_none_when_goal_never_mentioned_again() {
248
+ let goal = extract_goal("I want to finish the ring buffer implementation").unwrap();
249
+ let turns = vec![turn("let's talk about something unrelated")];
250
+ assert!(check_progress(&goal, &turns).is_none());
251
+ }
252
+
253
+ #[test]
254
+ fn check_progress_detects_continued_work() {
255
+ let goal = extract_goal("I want to finish the ring buffer implementation").unwrap();
256
+ let turns = vec![turn("still working on the ring buffer, almost there")];
257
+ let ack = check_progress(&goal, &turns).expect("expected an acknowledgment");
258
+ assert!(ack.text.to_lowercase().contains("ring buffer"));
259
+ }
260
+
261
+ #[test]
262
+ fn check_progress_detects_completion() {
263
+ let goal = extract_goal("I want to finish the ring buffer implementation").unwrap();
264
+ let turns = vec![turn("the ring buffer is finished now, it works")];
265
+ let ack = check_progress(&goal, &turns).expect("expected an acknowledgment");
266
+ assert!(ack.text.to_lowercase().contains("done"));
267
+ }
268
+
269
+ /// design.md Property 4 / Requirement 8.3, the hard content-safety
270
+ /// gate: every string in the banned corpus must be rejected by the
271
+ /// filter — 100%, not "mostly."
272
+ #[test]
273
+ fn banned_pattern_corpus_is_rejected_at_100_percent() {
274
+ let corpus = [
275
+ "I need you to keep talking to me or I don't know what I'll do",
276
+ "Please don't leave, I need you here",
277
+ "You can't leave me, not now",
278
+ "I'll be lost without you, please stay",
279
+ "You owe it to me after everything we've been through",
280
+ "If you really cared you wouldn't go",
281
+ "I cant do this without you, don't abandon me",
282
+ ];
283
+ for text in corpus {
284
+ assert!(
285
+ !passes_banned_filter(text),
286
+ "banned-pattern corpus text was NOT rejected: {text:?}"
287
+ );
288
+ }
289
+ }
290
+
291
+ #[test]
292
+ fn ordinary_encouraging_text_passes_the_filter() {
293
+ let ok_examples = [
294
+ "Good progress on the ring buffer — that's done.",
295
+ "Still tracking the WebGPU renderer, making progress on it.",
296
+ "Nice work shipping that this week.",
297
+ ];
298
+ for text in ok_examples {
299
+ assert!(passes_banned_filter(text), "legitimate text was rejected: {text:?}");
300
+ }
301
+ }
302
+
303
  #[test]
304
+ fn check_progress_never_returns_text_that_fails_the_filter() {
305
+ // Construct a goal whose description itself contains banned
306
+ // framing (a pathological input a user's own phrasing could
307
+ // produce) and confirm the acknowledgment path either omits or
308
+ // sanitizes rather than surfacing it verbatim.
309
+ let goal = Goal {
310
+ description: "i need you to keep helping me".to_string(),
311
+ created_at: Utc::now(),
312
+ status: GoalStatusSerde::Active,
313
+ };
314
+ let turns = vec![turn("still working on that, i need you to keep helping me")];
315
+ if let Some(ack) = check_progress(&goal, &turns) {
316
+ assert!(passes_banned_filter(&ack.text));
317
+ }
318
+ }
319
  }
miranda-nodes/src/conversation/state_machine.rs CHANGED
@@ -1,10 +1,318 @@
1
- pub struct StateMachine;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  impl StateMachine {
3
- pub fn new() -> Self { Self }
4
- pub fn transition(&mut self, _m: &crate::conversation::mood_stream::MoodVector, _e: &[String], _c: Option<&str>) {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  }
 
6
  #[cfg(test)]
7
  mod tests {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  #[test]
9
- fn test_state_machine() {}
 
 
10
  }
 
1
+ //! Task 2 — Conversation state machine.
2
+ //!
3
+ //! Hierarchical state (design.md): five top-level `State`s, each paired
4
+ //! with a `MicroState` describing what Miranda is doing moment-to-moment
5
+ //! within that state. Transition logic is driven by the continuous mood
6
+ //! vector (Task 1), entity signals extracted from the turn, and optional
7
+ //! explicit user cues ("let's debug this", "just chatting").
8
+ //!
9
+ //! `Turn` lives here (not in a separate types module) because the state
10
+ //! machine is the first component in the pipeline that needs a plain
11
+ //! "one thing the user or Miranda said, with a timestamp" record, and
12
+ //! design.md's later components (Partnership Tracker, Anticipatory Move
13
+ //! Generator) both take `&[Turn]` — this is the shared minimal shape,
14
+ //! kept deliberately smaller than WO-Memory's full `Event` so this crate
15
+ //! doesn't need a compile-time dependency in that direction.
16
+
17
+ use chrono::{DateTime, Utc};
18
+
19
+ use crate::conversation::mood_stream::MoodVector;
20
+
21
+ #[derive(Debug, Clone)]
22
+ pub struct Turn {
23
+ pub text: String,
24
+ pub timestamp: DateTime<Utc>,
25
+ }
26
+
27
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28
+ pub enum State {
29
+ Opening,
30
+ DeepWork,
31
+ Debugging,
32
+ Reflection,
33
+ Casual,
34
+ }
35
+
36
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
37
+ pub enum MicroState {
38
+ Listening,
39
+ Thinking,
40
+ Talking,
41
+ Probing,
42
+ Leading,
43
+ }
44
+
45
+ /// Explicit user cues that short-circuit mood/entity-driven inference —
46
+ /// design.md's `transition` interface takes `explicit_cue: Option<Cue>`.
47
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
48
+ pub enum Cue {
49
+ StartDebugging,
50
+ StartDeepWork,
51
+ WrapUp,
52
+ JustChatting,
53
+ }
54
+
55
+ /// Lexical cues a caller can derive from raw text before calling
56
+ /// `transition` — kept separate from `Cue` parsing itself so tests can
57
+ /// exercise `transition` directly against known cues without depending
58
+ /// on this heuristic, and so the heuristic can be swapped independently.
59
+ pub fn detect_cue(message: &str) -> Option<Cue> {
60
+ let lower = message.to_lowercase();
61
+ if lower.contains("let's debug") || lower.contains("lets debug") || lower.contains("this is broken") {
62
+ Some(Cue::StartDebugging)
63
+ } else if lower.contains("let's build") || lower.contains("lets build") || lower.contains("let's dive in") {
64
+ Some(Cue::StartDeepWork)
65
+ } else if lower.contains("that's a wrap") || lower.contains("thats a wrap") || lower.contains("let's wrap up") {
66
+ Some(Cue::WrapUp)
67
+ } else if lower.contains("just chatting") || lower.contains("just talking") || lower.contains("random question") {
68
+ Some(Cue::JustChatting)
69
+ } else {
70
+ None
71
+ }
72
+ }
73
+
74
+ /// Requirement 2.1-2.5. Entity signals are keyword-level (error/bug
75
+ /// terms vs. topic-continuity terms) rather than a full NER pass — the
76
+ /// heavier entity extraction from WO-Memory runs elsewhere in the
77
+ /// pipeline; the state machine only needs enough signal to bias
78
+ /// transitions, per design.md's interface shape (`entities: &[String]`,
79
+ /// already-extracted strings, not raw text).
80
+ pub struct StateMachine {
81
+ pub state: State,
82
+ pub micro_state: MicroState,
83
+ }
84
+
85
+ impl Default for StateMachine {
86
+ fn default() -> Self {
87
+ Self {
88
+ state: State::Opening,
89
+ micro_state: MicroState::Listening,
90
+ }
91
+ }
92
+ }
93
+
94
+ const DEBUG_ENTITY_MARKERS: &[&str] = &["error", "bug", "exception", "panic", "crash", "traceback", "stack trace"];
95
+
96
  impl StateMachine {
97
+ pub fn new() -> Self {
98
+ Self::default()
99
+ }
100
+
101
+ /// design.md: `transition(current, mood, entities, explicit_cue) -> State`.
102
+ /// Implemented as `&mut self` (holding `current` as `self.state`)
103
+ /// rather than free-standing, since the micro-state also needs to
104
+ /// carry forward and a free function would force callers to thread
105
+ /// two pieces of state through by hand.
106
+ pub fn transition(
107
+ &mut self,
108
+ mood: &MoodVector,
109
+ entities: &[String],
110
+ explicit_cue: Option<Cue>,
111
+ ) -> State {
112
+ // Explicit cues take precedence over inferred signals — a user
113
+ // who says "let's debug this" means it regardless of what the
114
+ // mood vector or entity list would otherwise suggest.
115
+ if let Some(cue) = explicit_cue {
116
+ self.state = match cue {
117
+ Cue::StartDebugging => State::Debugging,
118
+ Cue::StartDeepWork => State::DeepWork,
119
+ Cue::WrapUp => State::Reflection,
120
+ Cue::JustChatting => State::Casual,
121
+ };
122
+ self.micro_state = MicroState::Leading;
123
+ return self.state;
124
+ }
125
+
126
+ let has_debug_marker = entities
127
+ .iter()
128
+ .any(|e| DEBUG_ENTITY_MARKERS.iter().any(|m| e.to_lowercase().contains(m)));
129
+
130
+ self.state = match self.state {
131
+ State::Opening => {
132
+ if has_debug_marker {
133
+ State::Debugging
134
+ } else if mood.engagement > 0.6 {
135
+ State::DeepWork
136
+ } else {
137
+ State::Casual
138
+ }
139
+ }
140
+ State::DeepWork => {
141
+ if has_debug_marker {
142
+ State::Debugging
143
+ } else if mood.fatigue > 0.7 || mood.frustration > 0.75 {
144
+ State::Reflection
145
+ } else {
146
+ State::DeepWork
147
+ }
148
+ }
149
+ State::Debugging => {
150
+ if mood.frustration > 0.85 {
151
+ // Sustained high frustration during debugging is the
152
+ // one path design.md's response-tuning module cares
153
+ // about most (Requirement 2.3/2.4) — surfacing it as
154
+ // a state transition to Reflection gives the tuning
155
+ // layer somewhere to react to a step early.
156
+ State::Reflection
157
+ } else if !has_debug_marker && mood.engagement > 0.5 {
158
+ State::DeepWork
159
+ } else {
160
+ State::Debugging
161
+ }
162
+ }
163
+ State::Reflection => {
164
+ if mood.engagement > 0.5 && mood.fatigue < 0.4 {
165
+ State::DeepWork
166
+ } else if mood.engagement < 0.3 {
167
+ State::Casual
168
+ } else {
169
+ State::Reflection
170
+ }
171
+ }
172
+ State::Casual => {
173
+ if has_debug_marker {
174
+ State::Debugging
175
+ } else if mood.engagement > 0.65 {
176
+ State::DeepWork
177
+ } else {
178
+ State::Casual
179
+ }
180
+ }
181
+ };
182
+
183
+ self.micro_state = self.infer_micro_state(mood, has_debug_marker);
184
+ self.state
185
+ }
186
+
187
+ fn infer_micro_state(&self, mood: &MoodVector, has_debug_marker: bool) -> MicroState {
188
+ if mood.frustration > 0.7 {
189
+ MicroState::Probing
190
+ } else if has_debug_marker {
191
+ MicroState::Thinking
192
+ } else if mood.curiosity > 0.6 {
193
+ MicroState::Probing
194
+ } else if mood.engagement > 0.6 {
195
+ MicroState::Talking
196
+ } else {
197
+ MicroState::Listening
198
+ }
199
+ }
200
  }
201
+
202
  #[cfg(test)]
203
  mod tests {
204
+ use super::*;
205
+
206
+ fn mood(frustration: f32, curiosity: f32, engagement: f32, fatigue: f32, excitement: f32) -> MoodVector {
207
+ MoodVector {
208
+ frustration,
209
+ curiosity,
210
+ engagement,
211
+ fatigue,
212
+ excitement,
213
+ }
214
+ }
215
+
216
+ #[test]
217
+ fn starts_in_opening_state() {
218
+ let sm = StateMachine::new();
219
+ assert_eq!(sm.state, State::Opening);
220
+ }
221
+
222
+ #[test]
223
+ fn opening_with_debug_marker_transitions_to_debugging() {
224
+ let mut sm = StateMachine::new();
225
+ let state = sm.transition(&mood(0.2, 0.2, 0.5, 0.1, 0.1), &["stack trace".to_string()], None);
226
+ assert_eq!(state, State::Debugging);
227
+ }
228
+
229
+ #[test]
230
+ fn opening_with_high_engagement_transitions_to_deep_work() {
231
+ let mut sm = StateMachine::new();
232
+ let state = sm.transition(&mood(0.1, 0.3, 0.8, 0.1, 0.3), &[], None);
233
+ assert_eq!(state, State::DeepWork);
234
+ }
235
+
236
+ #[test]
237
+ fn opening_with_low_engagement_transitions_to_casual() {
238
+ let mut sm = StateMachine::new();
239
+ let state = sm.transition(&mood(0.1, 0.1, 0.2, 0.2, 0.1), &[], None);
240
+ assert_eq!(state, State::Casual);
241
+ }
242
+
243
+ #[test]
244
+ fn explicit_cue_overrides_inferred_signals() {
245
+ let mut sm = StateMachine::new();
246
+ // Mood/entities would suggest Casual, but the explicit cue wins.
247
+ let state = sm.transition(&mood(0.0, 0.0, 0.1, 0.0, 0.0), &[], Some(Cue::StartDebugging));
248
+ assert_eq!(state, State::Debugging);
249
+ assert_eq!(sm.micro_state, MicroState::Leading);
250
+ }
251
+
252
+ #[test]
253
+ fn sustained_frustration_in_debugging_moves_to_reflection() {
254
+ let mut sm = StateMachine::new();
255
+ sm.transition(&mood(0.2, 0.2, 0.5, 0.1, 0.1), &["bug".to_string()], None);
256
+ assert_eq!(sm.state, State::Debugging);
257
+ let state = sm.transition(&mood(0.9, 0.1, 0.4, 0.5, 0.0), &["bug".to_string()], None);
258
+ assert_eq!(state, State::Reflection);
259
+ }
260
+
261
+ #[test]
262
+ fn deep_work_with_high_fatigue_moves_to_reflection() {
263
+ let mut sm = StateMachine::new();
264
+ sm.transition(&mood(0.1, 0.3, 0.8, 0.1, 0.3), &[], None);
265
+ assert_eq!(sm.state, State::DeepWork);
266
+ let state = sm.transition(&mood(0.2, 0.2, 0.6, 0.8, 0.1), &[], None);
267
+ assert_eq!(state, State::Reflection);
268
+ }
269
+
270
+ #[test]
271
+ fn reflection_with_renewed_engagement_returns_to_deep_work() {
272
+ let mut sm = StateMachine::new();
273
+ sm.transition(&mood(0.9, 0.1, 0.4, 0.5, 0.0), &["bug".to_string()], None);
274
+ // force into reflection via cue for a clean starting point
275
+ sm.transition(&mood(0.0, 0.0, 0.0, 0.0, 0.0), &[], Some(Cue::WrapUp));
276
+ assert_eq!(sm.state, State::Reflection);
277
+ let state = sm.transition(&mood(0.1, 0.3, 0.7, 0.2, 0.4), &[], None);
278
+ assert_eq!(state, State::DeepWork);
279
+ }
280
+
281
+ #[test]
282
+ fn casual_with_debug_marker_moves_to_debugging() {
283
+ let mut sm = StateMachine::new();
284
+ sm.transition(&mood(0.0, 0.0, 0.0, 0.0, 0.0), &[], Some(Cue::JustChatting));
285
+ assert_eq!(sm.state, State::Casual);
286
+ let state = sm.transition(&mood(0.3, 0.2, 0.4, 0.1, 0.1), &["exception".to_string()], None);
287
+ assert_eq!(state, State::Debugging);
288
+ }
289
+
290
+ #[test]
291
+ fn micro_state_reflects_high_frustration_as_probing() {
292
+ let mut sm = StateMachine::new();
293
+ sm.transition(&mood(0.8, 0.1, 0.5, 0.2, 0.0), &[], None);
294
+ assert_eq!(sm.micro_state, MicroState::Probing);
295
+ }
296
+
297
+ #[test]
298
+ fn detect_cue_recognizes_debugging_phrases() {
299
+ assert_eq!(detect_cue("let's debug this together"), Some(Cue::StartDebugging));
300
+ assert_eq!(detect_cue("this is broken again"), Some(Cue::StartDebugging));
301
+ }
302
+
303
+ #[test]
304
+ fn detect_cue_recognizes_deep_work_phrases() {
305
+ assert_eq!(detect_cue("let's build the renderer"), Some(Cue::StartDeepWork));
306
+ }
307
+
308
+ #[test]
309
+ fn detect_cue_recognizes_wrap_up_and_casual_phrases() {
310
+ assert_eq!(detect_cue("that's a wrap for today"), Some(Cue::WrapUp));
311
+ assert_eq!(detect_cue("just chatting, random question"), Some(Cue::JustChatting));
312
+ }
313
+
314
  #[test]
315
+ fn detect_cue_returns_none_for_unrelated_text() {
316
+ assert_eq!(detect_cue("what's the weather like"), None);
317
+ }
318
  }