Skip to main content

mecha_core/
goal.rs

1//! A reference to something the agent is working toward.
2//!
3//! Nothing in mecha said what a run was *for*. `grep -i goal` over the crate
4//! returned four hits before this module, all of them incidental prose, and
5//! the consequence was structural rather than cosmetic: every evaluative
6//! signal the system has is either a person intervening or a counter crossing
7//! a threshold, so a run can be recorded as having gone badly and never as
8//! having gone well. `docs/GOAL-SYSTEM-DESIGN.md` is the argument; this is the
9//! reference type the rest of it is threaded on.
10//!
11//! **It is a pointer, not a copy.** A `Task` names a board task the knowledge
12//! graph owns, exactly as `kg_task_create` requires of its callers, and this
13//! module deliberately holds no title, status or due date. A second copy of
14//! somebody else's record is the thing that can disagree with it.
15//!
16//! **Three kinds, because there are three horizons** — a standing commitment,
17//! a current concern, a homeostatic setpoint. `Task` and `Charter` have stores
18//! behind them; `Setpoint` is named here because the wire format below has to
19//! survive its arrival, and because a reference whose kinds are invented one
20//! at a time acquires a fourth spelling of the same idea.
21//!
22//! ## The wire format, and why parsing has two policies
23//!
24//! A ref renders as `kind:id` — `task:01J8ZK…`. A flat string rather than a
25//! nested object because the *model* writes this: it is one field in a tool
26//! schema, and `malformed_tool_args` is a metric the harness grades models on.
27//! One string is harder to get wrong than `{"kind": …, "id": …}`.
28//!
29//! Reading one back has two directions and they get opposite treatment,
30//! following the rule `OutboxKind` and `Proposed` already set:
31//!
32//! - **From the model**, a malformed ref is an error reported back through
33//!   `ToolOutput`, because the model can fix it and silently dropping the
34//!   field would leave a plan claiming to serve nothing.
35//! - **From a record** — a transcript, a carried-state block — an unknown kind
36//!   degrades to *no reference*, never to a failed parse. Those are
37//!   append-only and may have been written by a newer binary; a strict reader
38//!   there would make one unrecognised word discard a whole plan.
39
40use std::fmt;
41use std::str::FromStr;
42
43/// What a piece of work serves.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum GoalRef {
46    /// A standing commitment from the charter (see [`crate::charter`]). The
47    /// id is a [`crate::charter::CharterLine`]'s own `id`.
48    ///
49    /// **Rank is the charter's line order, and is deliberately not carried
50    /// here.** Standing commitments conflict — protecting the owner against
51    /// not letting a colleague down — and value conflict is the measured cause
52    /// of goal drift, so the resolution has to be a total order rather than
53    /// weights: no quantity of a lower commitment outranks a higher one, which
54    /// is what makes *"this is urgent for very many people"* a non-argument.
55    /// Order in the file is that order, on `TASK-AGENT-DESIGN.md` R1's rule
56    /// one noun over — priority derives from the record and is never a field
57    /// anybody maintains, because a second statement of urgency disagrees with
58    /// the first the moment either is edited.
59    Charter(String),
60    /// A task on the GTD board, by the graph's own uid.
61    Task(String),
62    /// A homeostatic setpoint, by name. No store yet.
63    Setpoint(String),
64}
65
66impl GoalRef {
67    /// The kind word used on the wire.
68    pub fn kind(&self) -> &'static str {
69        match self {
70            GoalRef::Charter(_) => "charter",
71            GoalRef::Task(_) => "task",
72            GoalRef::Setpoint(_) => "setpoint",
73        }
74    }
75
76    /// The identifier this points at, without its kind.
77    pub fn id(&self) -> &str {
78        match self {
79            GoalRef::Charter(id) | GoalRef::Task(id) | GoalRef::Setpoint(id) => id,
80        }
81    }
82
83    /// Parse leniently: anything unrecognised is *no reference*.
84    ///
85    /// The reader for records. See the module note — a transcript written by a
86    /// newer binary must not cost the plan that surrounds it.
87    pub fn parse_lenient(s: &str) -> Option<GoalRef> {
88        s.parse().ok()
89    }
90}
91
92/// Serialised as the same `kind:id` string the model writes, never as an
93/// object.
94///
95/// One spelling on every wire this type crosses. A derived impl would give a
96/// stored record a second shape — `{"Task": "01J8ZK"}` — and then "what does a
97/// goal reference look like" would have two answers depending on which file
98/// you opened, which is how a reader written against one silently mis-reads
99/// the other.
100impl serde::Serialize for GoalRef {
101    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
102        s.collect_str(self)
103    }
104}
105
106/// Read an optional reference **out of a record**, leniently.
107///
108/// The record half of the module's two policies, as a function so it is
109/// decided once. A derived `Deserialize` could not express it: the lenient
110/// answer to an unknown kind is *no reference*, and a `Deserialize for
111/// GoalRef` must produce a `GoalRef` or fail the whole record. Reaching this
112/// through `Option` is what lets one unrecognised word cost the reference and
113/// nothing around it.
114pub fn de_lenient<'de, D>(d: D) -> Result<Option<GoalRef>, D::Error>
115where
116    D: serde::Deserializer<'de>,
117{
118    use serde::Deserialize;
119    Ok(Option::<String>::deserialize(d)?
120        .as_deref()
121        .and_then(GoalRef::parse_lenient))
122}
123
124/// The same, for a list. An unrecognised entry is dropped and its neighbours
125/// survive.
126pub fn de_lenient_vec<'de, D>(d: D) -> Result<Vec<GoalRef>, D::Error>
127where
128    D: serde::Deserializer<'de>,
129{
130    use serde::Deserialize;
131    Ok(Vec::<String>::deserialize(d)?
132        .iter()
133        .filter_map(|s| GoalRef::parse_lenient(s))
134        .collect())
135}
136
137impl fmt::Display for GoalRef {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        write!(f, "{}:{}", self.kind(), self.id())
140    }
141}
142
143/// Why a string was not a goal reference, phrased for whoever wrote it — which
144/// is usually a model reading the message back out of a `ToolOutput`.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ParseGoalRefError(String);
147
148impl fmt::Display for ParseGoalRefError {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.write_str(&self.0)
151    }
152}
153
154impl std::error::Error for ParseGoalRefError {}
155
156impl FromStr for GoalRef {
157    type Err = ParseGoalRefError;
158
159    fn from_str(s: &str) -> Result<Self, Self::Err> {
160        let s = s.trim();
161        // `split_once` and not `split(':')`: an id may contain a colon, and
162        // only the first one is the separator.
163        let Some((kind, id)) = s.split_once(':') else {
164            return Err(ParseGoalRefError(format!(
165                "`{s}` is not a goal reference; expected `task:<id>`"
166            )));
167        };
168        let id = id.trim();
169        if id.is_empty() {
170            return Err(ParseGoalRefError(format!(
171                "`{s}` names a kind with no identifier"
172            )));
173        }
174        match kind.trim() {
175            "charter" => Ok(GoalRef::Charter(id.to_string())),
176            "task" => Ok(GoalRef::Task(id.to_string())),
177            "setpoint" => Ok(GoalRef::Setpoint(id.to_string())),
178            other => Err(ParseGoalRefError(format!(
179                "`{other}` is not a kind of goal; expected charter, task or setpoint"
180            ))),
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn a_reference_round_trips_through_its_wire_form() {
191        for original in [
192            GoalRef::Task("01J8ZK".into()),
193            GoalRef::Charter("do-no-harm".into()),
194            GoalRef::Setpoint("attention-debt".into()),
195        ] {
196            let rendered = original.to_string();
197            assert_eq!(rendered.parse::<GoalRef>().unwrap(), original);
198        }
199    }
200
201    #[test]
202    fn an_id_may_contain_a_colon_because_only_the_first_one_separates() {
203        let r: GoalRef = "task:urn:uid:7".parse().unwrap();
204        assert_eq!(r, GoalRef::Task("urn:uid:7".into()));
205        assert_eq!(r.to_string(), "task:urn:uid:7");
206    }
207
208    /// The model-facing direction: a malformed reference is an error with a
209    /// message, because the model can fix it on the next call.
210    #[test]
211    fn a_malformed_reference_says_what_was_wrong() {
212        let no_colon = "notes.md".parse::<GoalRef>().unwrap_err().to_string();
213        assert!(no_colon.contains("not a goal reference"), "{no_colon}");
214
215        let bad_kind = "banana:7".parse::<GoalRef>().unwrap_err().to_string();
216        assert!(bad_kind.contains("not a kind of goal"), "{bad_kind}");
217
218        let no_id = "task:".parse::<GoalRef>().unwrap_err().to_string();
219        assert!(no_id.contains("no identifier"), "{no_id}");
220    }
221
222    /// The record-facing direction: the same inputs are simply absent. A
223    /// transcript written by a newer binary naming a kind this one has never
224    /// heard of must cost the reference and nothing else.
225    #[test]
226    fn a_record_with_an_unknown_kind_degrades_to_no_reference() {
227        assert_eq!(GoalRef::parse_lenient("epic:7"), None);
228        assert_eq!(GoalRef::parse_lenient("notes.md"), None);
229        assert_eq!(GoalRef::parse_lenient("task:"), None);
230        assert_eq!(
231            GoalRef::parse_lenient("task:7"),
232            Some(GoalRef::Task("7".into()))
233        );
234    }
235
236    #[test]
237    fn surrounding_whitespace_is_not_part_of_the_identifier() {
238        assert_eq!(
239            "  task: 01J8ZK  ".parse::<GoalRef>().unwrap(),
240            GoalRef::Task("01J8ZK".into())
241        );
242    }
243}