Skip to main content

mecha_core/
surface.rs

1//! The tool surface a run was sent, kept once and cited by hash.
2//!
3//! ## The gap this closes
4//!
5//! [`RunConfig`](crate::session::RunConfig) records the system prompt **in
6//! full**, and says why: *"the text lets a replay rebuild the request."* It
7//! records the tool surface as **names**, with a doc comment naming the risk it
8//! saw — *"a tool added, removed or renamed between recording and replay
9//! changes what the model could have done."*
10//!
11//! Add, remove and rename are the three that never happen. **Re-describe is the
12//! one that happens constantly** — 49 commits touched tool definitions in three
13//! weeks of this store — and it is invisible to a list of names. Render order
14//! is tools → system → messages, so a replay was rebuilding the *second* half
15//! of the prefix byte-exactly and the first half from whatever the registry
16//! says today. Every description edit since a recording silently changes the
17//! bytes the model sees before anything else.
18//!
19//! Measured: **12 of 13 counterfactual probes came back inconclusive**, on a
20//! pinned seed and a quiet box, deterministically across repeats — median
21//! divergence one tool call in. Six probes in one session, with steer points
22//! from 10 to 33, all gave up at the same call: a trajectory-dependent cause
23//! cannot do that, and a per-session constant can.
24//!
25//! ## Why a hash and a store rather than the specs inline
26//!
27//! Costed rather than preferred: the specs are **69 KB** against a **25 KB**
28//! average session file, so inlining would quadruple the session store and put,
29//! in most sessions, more bytes of tool description than conversation. A hash
30//! alone is cheap and gives up the rebuild, which is the point of recording it.
31//!
32//! So the specs are written once per distinct surface and cited by hash — the
33//! precedent is `ValidationRecord`'s `rules_hash`, *"keyed to the exact rule
34//! set measured, because a tally that mixes generations measures nothing."*
35//! Surfaces change tens of times over a corpus, not once per session, so the
36//! store dedupes to a few megabytes where inlining would cost tens.
37//!
38//! ## Three states, and the one that matters is `Unknown`
39//!
40//! A names-only recording must never read as *matching*. Every session written
41//! before this field exists — all of them, on the day it lands — is
42//! [`Fidelity::Unknown`], and a probe over one is inconclusive **for a named
43//! reason** instead of mysteriously. That is the whole reason this is
44//! `Option<String>` and not `String`: absent is not equal, the rule
45//! [`crate::homeostat`] and [`crate::backlog`] both state at length.
46//!
47//! [`Fidelity::Differs`] needs no blob at all — comparing today's hash against
48//! the recorded one answers it — so **legibility arrives the day the field
49//! ships and rebuildability accumulates afterwards**. That ordering is worth
50//! more than either half alone: it turns an inconclusive probe from a mystery
51//! into a labelled cause immediately.
52//!
53//! ## What it does not do
54//!
55//! **It does not recover the existing corpus.** Nothing can: those recordings
56//! never held the specs, and the descriptions they were sent are only in git
57//! history that cannot be matched to a session. The appraisal corpus and the
58//! validation ledger start from zero the day this ships, and anyone budgeting
59//! on the sessions already on disk should read that first.
60//!
61//! Nothing here is ever deleted, for the same reason a published bundle is not:
62//! a surface blob is what makes an old session replayable, and a retention
63//! policy over it would quietly cost the recordings it was keeping.
64
65use crate::message::ToolSpec;
66use anyhow::{Context, Result};
67use std::path::{Path, PathBuf};
68
69/// How faithfully a replay can reproduce what a recording was sent.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum Fidelity {
72    /// Today's surface hashes to the recorded value. The replay sends what the
73    /// recording sent.
74    Matches,
75    /// It provably does not, and the difference is in the bytes ahead of the
76    /// system prompt. A replay may still run; its divergences say nothing
77    /// about the question being probed.
78    Differs,
79    /// The recording predates the field, so there is nothing to compare. **Not
80    /// a match** — see the module note.
81    Unknown,
82}
83
84impl Fidelity {
85    /// Compare a recorded hash against a live surface.
86    pub fn of(recorded: Option<&str>, live: &[ToolSpec]) -> Fidelity {
87        match recorded {
88            None => Fidelity::Unknown,
89            Some(h) if h == fingerprint(live) => Fidelity::Matches,
90            Some(_) => Fidelity::Differs,
91        }
92    }
93
94    /// One phrase for a probe's `reason` field, or `None` when there is
95    /// nothing to say.
96    pub fn caveat(self) -> Option<&'static str> {
97        match self {
98            Fidelity::Matches => None,
99            Fidelity::Differs => Some(
100                "the tool surface has changed since this was recorded, so the replay sends \
101                 different bytes ahead of the system prompt",
102            ),
103            Fidelity::Unknown => Some(
104                "this was recorded before the tool surface was kept, so how faithfully it \
105                 replays is unknown",
106            ),
107        }
108    }
109}
110
111/// A stable identity for one tool surface.
112///
113/// Over the **rendered specs**, not the names — the whole point is that a
114/// re-described tool is a different surface under the same name. Order is the
115/// registry's, which is `BTreeMap` order and therefore stable, and is itself
116/// part of what the model saw: the tool list is the front of the cached prefix.
117///
118/// **`learning::rules_hash`'s hasher, not `DefaultHasher`.** This value is
119/// both a comparison key and a filename, so the caveat that function's own
120/// doc comment states — *"the std hasher is deliberately unstable across
121/// Rust releases, and a ledger key that drifts with the toolchain would
122/// silently split every tally"* — bites harder here: a toolchain bump would
123/// make [`Fidelity::of`] read every session recorded on the old one as
124/// `Differs`, permanently and indistinguishably from real re-describe drift,
125/// and orphan every blob already written in the store this module's own note
126/// says is never pruned. A canonical rendering fed through the same hash
127/// this codebase already trusts for a persisted key, rather than a second
128/// hand-rolled FNV-1a, so there is one definition to keep stable.
129pub fn fingerprint(specs: &[ToolSpec]) -> String {
130    let mut rendered = String::new();
131    for spec in specs {
132        rendered.push_str(&spec.name);
133        rendered.push('\0');
134        rendered.push_str(&spec.description);
135        rendered.push('\0');
136        // `serde_json::Map` is a `BTreeMap`, so this rendering is canonical
137        // whatever order a schema was built in.
138        rendered.push_str(&spec.input_schema.to_string());
139        rendered.push('\0');
140    }
141    crate::learning::rules_hash(&rendered)
142}
143
144/// Where surface blobs live. One file per distinct surface, named by its hash.
145pub struct SurfaceStore {
146    root: PathBuf,
147}
148
149impl SurfaceStore {
150    pub fn default_root() -> Result<PathBuf> {
151        Ok(crate::work::mecha_home()?.join("surfaces"))
152    }
153
154    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
155        let root = root.into();
156        // Owner-only, like every other store root under `~/.mecha` — the
157        // front door's own rule for the same reason: these blobs are not
158        // nothing. A `ToolSpec` carries the mail account short names baked
159        // into every tool schema as an enum at startup, plus whatever an MCP
160        // server put in its own descriptions.
161        crate::create_private_dir(&root).with_context(|| format!("creating {}", root.display()))?;
162        Ok(SurfaceStore { root })
163    }
164
165    /// Open the default store, or `None` if it cannot be reached.
166    ///
167    /// Best-effort by design: recording a surface is bookkeeping beside a run,
168    /// and a full disk must not stop the run itself. A session that could not
169    /// record its surface carries no hash and reads back as
170    /// [`Fidelity::Unknown`], which is exactly true.
171    pub fn open_default() -> Option<Self> {
172        Self::default_root().ok().and_then(|r| Self::open(r).ok())
173    }
174
175    fn path(&self, hash: &str) -> PathBuf {
176        self.root.join(format!("{hash}.json"))
177    }
178
179    /// Record a surface and return its hash. A surface already on disk costs
180    /// one `exists` and no write.
181    pub fn record(&self, specs: &[ToolSpec]) -> Result<String> {
182        let hash = fingerprint(specs);
183        let path = self.path(&hash);
184        if path.exists() {
185            return Ok(hash);
186        }
187        // Temp-and-rename on the store convention: a crash mid-write must
188        // leave no half-file under a name that claims to be a whole surface.
189        let tmp = self.root.join(format!("{hash}.json.tmp"));
190        std::fs::write(&tmp, serde_json::to_vec_pretty(specs)?)
191            .with_context(|| format!("writing {}", tmp.display()))?;
192        std::fs::rename(&tmp, &path)?;
193        Ok(hash)
194    }
195
196    /// The specs behind a hash, or `None` when the blob is not here.
197    ///
198    /// Absent is not empty: a missing blob means the surface cannot be rebuilt,
199    /// never that the run had no tools.
200    pub fn load(&self, hash: &str) -> Option<Vec<ToolSpec>> {
201        let text = std::fs::read_to_string(self.path(hash)).ok()?;
202        serde_json::from_str(&text).ok()
203    }
204
205    pub fn root(&self) -> &Path {
206        &self.root
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use serde_json::json;
214
215    fn spec(name: &str, description: &str) -> ToolSpec {
216        ToolSpec {
217            name: name.into(),
218            description: description.into(),
219            input_schema: json!({"type": "object"}),
220        }
221    }
222
223    fn scratch() -> SurfaceStore {
224        let dir = std::env::temp_dir().join(format!(
225            "mecha-surface-test-{}-{}",
226            std::process::id(),
227            uuid::Uuid::new_v4()
228        ));
229        SurfaceStore::open(dir).unwrap()
230    }
231
232    /// Owner-only, on the front door's rule for every store root under
233    /// `~/.mecha`: a `ToolSpec` carries the mail account short names baked
234    /// into every tool schema, plus whatever an MCP server put in its own
235    /// descriptions, so this is not nothing to protect.
236    #[test]
237    fn the_surface_store_directory_is_owner_only() {
238        use std::os::unix::fs::PermissionsExt;
239        let dir =
240            std::env::temp_dir().join(format!("mecha-surface-perms-{}", uuid::Uuid::new_v4()));
241        SurfaceStore::open(&dir).unwrap();
242        let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
243        assert_eq!(mode & 0o777, 0o700);
244        std::fs::remove_dir_all(&dir).ok();
245    }
246
247    /// The failure this module exists for: a name list cannot see it.
248    #[test]
249    fn a_re_described_tool_is_a_different_surface() {
250        let before = [spec("fs_read", "Read a file.")];
251        let after = [spec(
252            "fs_read",
253            "Read a file. Paths are workspace-relative.",
254        )];
255        assert_ne!(fingerprint(&before), fingerprint(&after));
256        assert_eq!(
257            before.iter().map(|s| &s.name).collect::<Vec<_>>(),
258            after.iter().map(|s| &s.name).collect::<Vec<_>>(),
259            "…and the names are identical, which is the whole problem"
260        );
261    }
262
263    #[test]
264    fn a_changed_schema_is_a_different_surface_too() {
265        let mut other = spec("todo", "Keep a plan.");
266        other.input_schema = json!({"type": "object", "properties": {"serves": {}}});
267        assert_ne!(
268            fingerprint(&[spec("todo", "Keep a plan.")]),
269            fingerprint(&[other])
270        );
271    }
272
273    #[test]
274    fn the_same_surface_hashes_the_same_twice() {
275        let s = [spec("a", "x"), spec("b", "y")];
276        assert_eq!(fingerprint(&s), fingerprint(&s));
277        // Order is part of the surface: it is the front of the cached prefix.
278        let flipped = [spec("b", "y"), spec("a", "x")];
279        assert_ne!(fingerprint(&s), fingerprint(&flipped));
280    }
281
282    /// The other three tests here compare two values computed in the same
283    /// process, so none of them can fail if this hashed with the *wrong*
284    /// hasher — the mistake this asserts against directly, on
285    /// `the_rules_hash_is_stable_forever`'s precedent one module over. Pinned
286    /// to `learning::rules_hash` over the exact canonical rendering rather
287    /// than a second hex literal, so a change to either the rendering or the
288    /// choice of hasher shows up here rather than only in a toolchain bump
289    /// nobody connects back to this file.
290    #[test]
291    fn fingerprint_uses_the_stable_hasher_not_the_std_one() {
292        let one = spec("build", "Build it.");
293        let expected = crate::learning::rules_hash("build\0Build it.\0{\"type\":\"object\"}\0");
294        assert_eq!(fingerprint(&[one]), expected);
295    }
296
297    /// **The rule the whole design turns on.** Every session on disk the day
298    /// this ships has no hash, and none of them may read as faithful.
299    #[test]
300    fn a_recording_with_no_hash_is_unknown_and_never_a_match() {
301        let live = [spec("fs_read", "Read a file.")];
302        assert_eq!(Fidelity::of(None, &live), Fidelity::Unknown);
303        assert!(Fidelity::Unknown.caveat().is_some());
304        assert_ne!(Fidelity::of(None, &live), Fidelity::Matches);
305    }
306
307    /// And `Differs` needs no blob — which is why legibility lands the day the
308    /// field ships, before any surface has accumulated.
309    #[test]
310    fn drift_is_detectable_with_nothing_but_the_hash() {
311        let recorded = fingerprint(&[spec("fs_read", "Read a file.")]);
312        let live = [spec(
313            "fs_read",
314            "Read a file. Paths are workspace-relative.",
315        )];
316        assert_eq!(Fidelity::of(Some(&recorded), &live), Fidelity::Differs);
317        assert!(Fidelity::Differs.caveat().unwrap().contains("changed"));
318
319        // No store was opened anywhere in this test.
320        let same = [spec("fs_read", "Read a file.")];
321        assert_eq!(Fidelity::of(Some(&recorded), &same), Fidelity::Matches);
322        assert!(Fidelity::Matches.caveat().is_none());
323    }
324
325    #[test]
326    fn a_surface_round_trips_and_a_second_record_writes_nothing_new() {
327        let store = scratch();
328        let specs = vec![spec("a", "one"), spec("b", "two")];
329        let hash = store.record(&specs).unwrap();
330        // Compared by fingerprint rather than by field, which is also the
331        // assertion that matters: what round-trips is the *surface*.
332        assert_eq!(fingerprint(&store.load(&hash).unwrap()), hash);
333
334        let again = store.record(&specs).unwrap();
335        assert_eq!(again, hash);
336        let files = std::fs::read_dir(store.root()).unwrap().count();
337        assert_eq!(files, 1, "one blob per distinct surface, not per record");
338    }
339
340    /// A missing blob is a surface that cannot be rebuilt, never a run that
341    /// had no tools — the distinction every reader over these stores makes.
342    #[test]
343    fn a_missing_blob_is_absent_and_not_empty() {
344        let store = scratch();
345        assert!(store.load("0000000000000000").is_none());
346    }
347}