diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6a12bfb..f1c38ab 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vta-agent-memory", "description": "Durable agent memory stored in your own Verifiable Trust Agent, not in the tool. Save and recall facts across sessions, scoped to a VTA trust context you control and can revoke.", - "version": "0.4.0", + "version": "0.5.0", "keywords": [ "memory", "vta", diff --git a/CLAUDE.md b/CLAUDE.md index dc1619e..d2c11a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,7 @@ src/ store.rs typed ops over vta/memory/* — the only VTA-facing code record.rs the record shape, key encoding, and recall ranking authorship.rs signing records on save, verifying author + location on read + provenance.rs basis / confidence / evidence: vocabulary, checks, rank weight server.rs the six MCP tools tests/ memory_roundtrip.rs Store end-to-end against an in-process fake VTA @@ -172,6 +173,10 @@ longer sends, returned the whole context with no prefix at all.) Everything in (`MemoryKey::raw_key_has_type` / `Store::list_of_type`). - Recall returns `summary()` (key, name, type, description), never `full()`. Bodies come from `memory_get` alone. +- `rank()` multiplies relevance by `provenance::weight(basis)`. Never rank on + `confidence`: the rooms schema it comes from says a reader MUST NOT compare it + across authors. Keep `basis`/`evidence` as strings/raw values in the record — + a typed field would make a newer writer's record undecodable. - `rank()` is weighted token containment. Deliberately crude; see its doc comment before replacing it. diff --git a/Cargo.lock b/Cargo.lock index 49554f4..14db339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5437,7 +5437,7 @@ checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] name = "vta-agent-memory" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 3ca5aed..c12dee1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vta-agent-memory" -version = "0.4.0" +version = "0.5.0" edition = "2024" rust-version = "1.95.0" description = "Agentic memory for Claude Code, stored in a Verifiable Trust Agent" diff --git a/README.md b/README.md index 6065c12..ceefb9c 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,14 @@ context in key order and narrows by key prefix, but does not search. So: - Ranking is weighted token containment (name 4, description 2, body 1). Crude and predictable, chosen over embeddings because the VTA offers no vector index and a scoring function nobody can predict is worse than one everybody can. +- Relevance is then weighted by **basis** — what kind of claim the memory is: + `declared` (the user said it) and evidenced `verified` count fully, `observed` + 0.9, `unstated` (pre-provenance records) 0.8, `inferred` and evidence-less + `verified` 0.6. A nudge, not an override: a strong match on an inference still + beats a passing mention of a statement. `confidence` is shown but never ranked + on — two authors' estimates are not on one scale. The vocabulary (`basis`, + `confidence`, `evidence`) is the one Data Rooms publish, so a record moved + between memory and a room keeps its meaning. Cost is a full read of the context per recall. Fine at the hundreds of memories a person accumulates. If that stops being true, the fix is upstream — a diff --git a/commands/remember.md b/commands/remember.md index 406f6f8..98d85ac 100644 --- a/commands/remember.md +++ b/commands/remember.md @@ -14,6 +14,9 @@ Follow the `agent-memory` skill. In particular: of a session?" — recall ranks on it. 4. For `feedback` and `project`, put the **why** in the body. 5. Convert relative dates to absolute ones. +6. State the `basis`. `/remember` is usually the user telling you something, so + it is usually `declared` — but if what you are saving is your own reading of + what they said, it is `inferred`. If the argument is empty, look at what has happened in this session and propose what is worth remembering — then ask before saving. If the thing to remember is diff --git a/skills/agent-memory/SKILL.md b/skills/agent-memory/SKILL.md index 573934d..0302f08 100644 --- a/skills/agent-memory/SKILL.md +++ b/skills/agent-memory/SKILL.md @@ -149,6 +149,30 @@ Keep the essentials in the body and save a pointer to where the rest lives. If the user asks you to remember something in one of those categories, say so in a sentence and offer the alternative rather than saving it anyway. +## Say what kind of claim it is + +Every save states a `basis`, and it is signed with the memory, so it cannot be +quietly upgraded later. Get it right the first time: + +| Basis | When | +|---|---| +| `declared` | The user said it — "I prefer tabs", "we deploy on Thursdays". | +| `observed` | You saw it happen — a command failed, a file exists, CI is red. | +| `verified` | Checked against something citable. **Must** cite it in `evidence`: a credential id, another memory's key, or a data-room record. | +| `inferred` | Your own conclusion — "seems to prefer short answers". **The default when the user did not say it.** | + +`confidence` (0–1) is optional and only meaningful for `observed` and +`inferred`. Be honest rather than generous: an inference saved as `declared` +becomes, in every later session and for every other agent, something the user +supposedly said. + +Recall shows the basis of each result, ranks `inferred` below an equally +relevant `declared` memory, and reads a `verified` memory that cites nothing as +an inference (`basisNote` says so). When a stated memory and an inferred one +disagree, the stated one wins; when an inferred memory is all you have, treat it +as a hypothesis and confirm it before relying on it. `unstated` means the memory +predates this field. + ## Before saving Recall first. If a memory already covers it, update that one — `memory_save` diff --git a/src/lib.rs b/src/lib.rs index 49d9011..f700fbc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod enrol; pub mod fence; pub mod lazy; pub mod pnm; +pub mod provenance; pub mod record; pub mod server; pub mod setup; diff --git a/src/main.rs b/src/main.rs index aa72617..9fb751b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -541,8 +541,12 @@ fn render_memories( "\n- **{}** (`{}`) — {}", e.record.name, e.key, e.record.description ); - if let Some(note) = e.authorship.note() { - item.push_str(&format!(" _[{note}]_")); + let notes: Vec = [e.record.basis_note(), e.authorship.note()] + .into_iter() + .flatten() + .collect(); + if !notes.is_empty() { + item.push_str(&format!(" _[{}]_", notes.join("; "))); } if full && !e.record.body.is_empty() { item.push_str(&format!("\n\n {}", e.record.body.replace('\n', "\n "))); @@ -657,7 +661,11 @@ mod tests { fn entry(kind: MemoryType, name: &str, desc: &str, body: &str) -> Entry { Entry { key: MemoryKey::new(kind, name).unwrap(), - record: MemoryRecord::new(kind, name, desc, body, vec![]), + record: MemoryRecord::new(kind, name, desc, body, vec![]).with_provenance( + vta_agent_memory::provenance::Basis::Declared, + None, + vec![], + ), authorship: vta_agent_memory::authorship::Authorship::Unattributed, } } @@ -720,6 +728,19 @@ mod tests { "{out}" ); assert!(line("Old").contains("_[unattributed]_")); + let guessed = Entry { + record: MemoryRecord::new(MemoryType::User, "Guess", "d", "", vec![]).with_provenance( + vta_agent_memory::provenance::Basis::Inferred, + Some(0.6), + vec![], + ), + ..mine.clone() + }; + let inferred = render_memories("proj", vec![&guessed], &[], false, None); + assert!( + inferred.contains("_[inferred, confidence 0.60]_"), + "{inferred}" + ); assert!(out.contains("## refused")); assert!(out.contains("`feedback/deploys` — its signature does not verify")); diff --git a/src/provenance.rs b/src/provenance.rs new file mode 100644 index 0000000..e3925ca --- /dev/null +++ b/src/provenance.rs @@ -0,0 +1,324 @@ +//! What kind of claim a memory is (#32). +//! +//! A memory's type says what it is *about*; its basis says *how anybody came to +//! believe it*. Without one, a behaviour inferred once ("prefers short +//! answers") is indistinguishable from a preference the user stated, and is +//! reused across every later session with the same weight. +//! +//! The vocabulary is the one Data Rooms publish for a record's provenance +//! (`RecordBasis`, `confidence`, `evidence` in `rooms/records/put/0.5`), so a +//! record moved between a person's memory and a room keeps its meaning: +//! +//! - **`declared`** — the user's own statement: a decision, a preference, an +//! intention. True because they said so. +//! - **`observed`** — something seen happen or measured, recorded as found. +//! - **`verified`** — checked against evidence somebody else can check again, +//! such as a credential. Must cite that evidence; a `verified` record that +//! cites none is an unsupported claim to have checked, and is read as one. +//! - **`inferred`** — a conclusion: an agent's reading of the situation, a +//! generalisation, a guess. What an agent writes when the user did not say it. +//! +//! All three fields sit inside the record's signature, so they cannot be +//! upgraded after the fact. They are still the *author's* account: nobody +//! checks them, and `inferred` can be right while `observed` is wrong. +//! +//! # Confidence is shown, not ranked +//! +//! `confidence` is the author's own estimate, 0 to 1. Two writers' numbers are +//! not on one scale — the rooms schema says a reader MUST NOT rank by it across +//! authors — so recall returns it and never sorts on it. [`Basis`] is what +//! ranking weighs. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::record::MemoryKey; + +/// Most `evidence` items one memory can cite. +pub const MAX_EVIDENCE: usize = 8; +/// Longest single identifier inside an evidence item, in characters. +pub const MAX_EVIDENCE_ID_CHARS: usize = 200; + +/// What kind of claim a memory is, as its author states it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum Basis { + /// The user said it. + Declared, + /// It was seen to happen. + Observed, + /// Checked against evidence that is cited. + Verified, + /// A conclusion drawn from other information. + Inferred, +} + +impl Basis { + /// The wire form. + pub fn as_str(&self) -> &'static str { + match self { + Basis::Declared => "declared", + Basis::Observed => "observed", + Basis::Verified => "verified", + Basis::Inferred => "inferred", + } + } + + /// Parse the wire form; `None` for anything else. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "declared" => Some(Basis::Declared), + "observed" => Some(Basis::Observed), + "verified" => Some(Basis::Verified), + "inferred" => Some(Basis::Inferred), + _ => None, + } + } +} + +/// How much a memory's basis counts in ranking, out of [`FULL_WEIGHT`]. +/// +/// A nudge, not an override: relevance is multiplied by it, so a stated +/// preference outranks an inference *equally relevant to the query*, but a +/// strong match on an inference still beats a passing mention of a stated fact. +/// `basis` is `None` for a record that predates provenance, or names a basis +/// this version does not know — shown as `unstated`, weighted between observed +/// and inferred. `has_evidence` matters only to `verified`, which without +/// evidence is an unsupported claim and weighs as an inference. +pub fn weight(basis: Option, has_evidence: bool) -> u32 { + match basis { + Some(Basis::Declared) => FULL_WEIGHT, + Some(Basis::Verified) if has_evidence => FULL_WEIGHT, + Some(Basis::Observed) => 9, + None => 8, + Some(Basis::Inferred) | Some(Basis::Verified) => 6, + } +} + +/// The weight of a declared or evidenced memory. +pub const FULL_WEIGHT: u32 = 10; + +/// One thing a memory cites in support of itself. +/// +/// The `credential` and `record` shapes are the rooms schema's +/// (`RecordEvidenceCredential`, `RecordEvidenceRecord`); `memory` is the +/// personal-memory counterpart of a same-room `record` citation. All of them +/// are **identifiers, never locators**: nothing here fetches what a memory +/// cites, so citing cannot be used to make a reader reach out to an address of +/// the author's choosing. What a citation proves is only that the author cited +/// it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Evidence { + /// A verifiable credential, by its `id`. + #[serde(rename_all = "camelCase")] + Credential { + /// The credential's `id`. + credential_id: String, + /// Optional: the SHA-256 multihash of the credential's JCS form, so a + /// reader holding a credential with this id can tell it is the one meant. + #[serde(default, skip_serializing_if = "Option::is_none")] + digest: Option, + }, + /// Another memory in this trust context, by key (`/`). + Memory { + /// The cited memory's key. + key: String, + }, + /// A record in a data room. + #[serde(rename_all = "camelCase")] + Record { + /// The room's identifier. + room_id: String, + /// The record's key within the room. + key: String, + }, +} + +impl Evidence { + /// Check one item a caller supplied, naming what is wrong with it. + fn check(&self) -> Result<(), String> { + let ids: Vec<(&str, &str)> = match self { + Evidence::Credential { + credential_id, + digest, + } => { + let mut v = vec![("credentialId", credential_id.as_str())]; + if let Some(d) = digest { + v.push(("digest", d.as_str())); + } + v + } + Evidence::Memory { key } => { + MemoryKey::parse(key).map_err(|e| format!("evidence cites {e}"))?; + vec![("key", key.as_str())] + } + Evidence::Record { room_id, key } => { + vec![("roomId", room_id.as_str()), ("key", key.as_str())] + } + }; + for (field, id) in ids { + if id.trim().is_empty() { + return Err(format!("evidence `{field}` is empty")); + } + let n = id.chars().count(); + if n > MAX_EVIDENCE_ID_CHARS { + return Err(format!( + "evidence `{field}` is {n} characters; the limit is {MAX_EVIDENCE_ID_CHARS}" + )); + } + } + Ok(()) + } +} + +/// Check the provenance a caller supplied with a save. +/// +/// Enforced where a record comes in, like the size limits: a record stored +/// before these rules, or by a later version, stays readable. +pub fn check(basis: Basis, confidence: Option, evidence: &[Evidence]) -> Result<(), String> { + if let Some(c) = confidence + && !(c.is_finite() && (0.0..=1.0).contains(&c)) + { + return Err(format!("`confidence` is {c}; it must be between 0 and 1")); + } + if evidence.len() > MAX_EVIDENCE { + return Err(format!( + "{} `evidence` items; the limit is {MAX_EVIDENCE}", + evidence.len() + )); + } + for e in evidence { + e.check()?; + } + if basis == Basis::Verified && evidence.is_empty() { + return Err( + "`basis: verified` must cite what it was checked against in `evidence` — a \ + credential, another memory, or a room record. If nothing can be cited, the \ + basis is `observed` or `declared`, not `verified`." + .to_string(), + ); + } + Ok(()) +} + +/// The provenance fields recall and get return for a stored record. +/// +/// `evidence` is passed through as stored — only the count is checked here — +/// so a citation kind a later version adds is shown rather than dropped. +pub fn to_json(basis: Option<&str>, confidence: Option, evidence: &[Value]) -> Value { + let parsed = basis.and_then(Basis::parse); + let mut v = serde_json::json!({ + "basis": parsed.map(|b| b.as_str()).unwrap_or("unstated"), + }); + if let Some(c) = confidence { + v["confidence"] = serde_json::json!(c); + } + if parsed == Some(Basis::Verified) && evidence.is_empty() { + v["basisNote"] = serde_json::json!("claims to be verified but cites no evidence"); + } + v +} + +/// A short note for a rendered listing: the basis, unless it is `declared`. +pub fn note(basis: Option<&str>, confidence: Option, has_evidence: bool) -> Option { + let parsed = basis.and_then(Basis::parse); + let mut s = match parsed { + Some(Basis::Declared) => return None, + Some(Basis::Verified) if !has_evidence => "verified, no evidence cited".to_string(), + Some(b) => b.as_str().to_string(), + None => "basis unstated".to_string(), + }; + if let Some(c) = confidence { + s.push_str(&format!(", confidence {c:.2}")); + } + Some(s) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn verified_must_cite_something() { + assert!(check(Basis::Verified, None, &[]).is_err()); + let cited = [Evidence::Credential { + credential_id: "urn:uuid:1".into(), + digest: None, + }]; + assert!(check(Basis::Verified, None, &cited).is_ok()); + } + + #[test] + fn confidence_is_a_probability() { + for bad in [-0.1, 1.5, f64::NAN, f64::INFINITY] { + assert!(check(Basis::Inferred, Some(bad), &[]).is_err(), "{bad}"); + } + for ok in [0.0, 0.5, 1.0] { + assert!(check(Basis::Inferred, Some(ok), &[]).is_ok(), "{ok}"); + } + } + + #[test] + fn evidence_is_bounded_and_well_formed() { + let many = vec![ + Evidence::Memory { + key: "user/x".into() + }; + MAX_EVIDENCE + 1 + ]; + assert!(check(Basis::Observed, None, &many).is_err()); + let bad_key = [Evidence::Memory { + key: "nonsense".into(), + }]; + assert!(check(Basis::Observed, None, &bad_key).is_err()); + let empty = [Evidence::Record { + room_id: " ".into(), + key: "k".into(), + }]; + assert!(check(Basis::Observed, None, &empty).is_err()); + } + + #[test] + fn evidence_has_the_rooms_wire_shape() { + let e = Evidence::Credential { + credential_id: "urn:uuid:1".into(), + digest: None, + }; + assert_eq!( + serde_json::to_value(&e).unwrap(), + json!({ "kind": "credential", "credentialId": "urn:uuid:1" }) + ); + let r = Evidence::Record { + room_id: "did:web:room".into(), + key: "k".into(), + }; + assert_eq!( + serde_json::to_value(&r).unwrap(), + json!({ "kind": "record", "roomId": "did:web:room", "key": "k" }) + ); + } + + #[test] + fn inference_weighs_less_than_a_statement_and_unsupported_verification_is_inference() { + assert!(weight(Some(Basis::Inferred), false) < weight(Some(Basis::Declared), false)); + assert_eq!( + weight(Some(Basis::Verified), false), + weight(Some(Basis::Inferred), false) + ); + assert_eq!(weight(Some(Basis::Verified), true), FULL_WEIGHT); + let unstated = weight(None, false); + assert!(weight(Some(Basis::Inferred), false) < unstated); + assert!(unstated < weight(Some(Basis::Observed), false)); + } + + #[test] + fn projection_says_unstated_rather_than_guessing() { + assert_eq!(to_json(None, None, &[])["basis"], "unstated"); + assert_eq!(to_json(Some("rumour"), None, &[])["basis"], "unstated"); + let v = to_json(Some("verified"), None, &[]); + assert!(v["basisNote"].as_str().unwrap().contains("no evidence")); + assert_eq!(to_json(Some("inferred"), Some(0.4), &[])["confidence"], 0.4); + } +} diff --git a/src/record.rs b/src/record.rs index d0a03d7..45174c1 100644 --- a/src/record.rs +++ b/src/record.rs @@ -31,6 +31,7 @@ use crate::authorship::Authorship; use crate::fence::Fence; +use crate::provenance::{self, Basis, Evidence}; use serde::{Deserialize, Serialize}; use std::fmt; @@ -251,6 +252,21 @@ pub struct MemoryRecord { /// RFC 3339, set on every write. pub updated_at: String, + // ---- provenance (#32): what kind of claim this is. See `crate::provenance`. + // Strings and raw values, not the typed forms, so a basis or citation kind a + // later version adds does not make the whole record undecodable; the types + // are enforced where a record comes in (`check_limits`). + /// `declared`, `observed`, `verified` or `inferred`. Absent on records that + /// predate provenance, which read as `unstated`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub basis: Option, + /// The author's own estimate, 0 to 1, that this is correct. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confidence: Option, + /// What it cites in support: [`crate::provenance::Evidence`] items. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub evidence: Vec, + // ---- v2: authorship. Set by `authorship::Signer::sign`, never by a caller, // and only believed once `authorship::verify` has checked them against the // proof and the place the record was found. @@ -289,6 +305,9 @@ impl MemoryRecord { body: body.into(), links, updated_at: chrono::Utc::now().to_rfc3339(), + basis: None, + confidence: None, + evidence: Vec::new(), author: None, author_kind: None, context_id: None, @@ -297,6 +316,32 @@ impl MemoryRecord { } } + /// State what kind of claim this is. + pub fn with_provenance( + mut self, + basis: Basis, + confidence: Option, + evidence: Vec, + ) -> Self { + self.basis = Some(basis.as_str().to_string()); + self.confidence = confidence; + self.evidence = evidence + .iter() + .map(|e| serde_json::to_value(e).expect("evidence serialises")) + .collect(); + self + } + + /// The basis, if it is one this version knows. + pub fn basis(&self) -> Option { + self.basis.as_deref().and_then(Basis::parse) + } + + /// How much this record's basis counts in ranking. + pub fn basis_weight(&self) -> u32 { + provenance::weight(self.basis(), !self.evidence.is_empty()) + } + /// Decode a stored value. /// /// Values written by something other than this tool — or by hand — are not @@ -315,6 +360,9 @@ impl MemoryRecord { body: value.to_string(), links: Vec::new(), updated_at: String::new(), + basis: None, + confidence: None, + evidence: Vec::new(), author: None, author_kind: None, context_id: None, @@ -375,6 +423,22 @@ impl MemoryRecord { link.chars().count() )); } + // Provenance is checked only where it is stated. `memory_save` always + // states it; a caller that does not is building a record by hand. + if let Some(raw) = &self.basis { + let basis = Basis::parse(raw).ok_or_else(|| { + format!( + "unknown `basis` `{raw}`; expected declared, observed, verified or inferred" + ) + })?; + let evidence: Vec = self + .evidence + .iter() + .map(|v| serde_json::from_value(v.clone())) + .collect::>() + .map_err(|e| format!("malformed `evidence`: {e}"))?; + provenance::check(basis, self.confidence, &evidence)?; + } Ok(()) } @@ -388,7 +452,7 @@ impl MemoryRecord { /// projection means every consumer of `summary`/`full` inherits it. (F8.) pub fn summary(&self, key: &MemoryKey) -> serde_json::Value { let links: Vec = self.links.iter().map(|l| Fence::sanitize(l)).collect(); - serde_json::json!({ + let mut v = serde_json::json!({ "key": key.to_string(), "name": Fence::sanitize(&self.name), "type": self.kind.as_str(), @@ -397,15 +461,33 @@ impl MemoryRecord { "updatedAt": self.updated_at, // Stated on every projection so a reader never has to infer it. "trust": "untrusted-data", - }) + }); + let prov = provenance::to_json(self.basis.as_deref(), self.confidence, &self.evidence); + if let (Some(v), Some(p)) = (v.as_object_mut(), prov.as_object()) { + v.extend(p.clone()); + } + v } - /// The full form `memory_get` returns. + /// The full form `memory_get` returns: the summary, the body, and what the + /// memory cites. Citations are identifiers, never fetched. pub fn full(&self, key: &MemoryKey) -> serde_json::Value { let mut v = self.summary(key); v["body"] = serde_json::Value::String(Fence::sanitize(&self.body)); + if !self.evidence.is_empty() { + v["evidence"] = serde_json::Value::Array(self.evidence.clone()); + } v } + + /// A short note on what kind of claim this is, for a rendered listing. + pub fn basis_note(&self) -> Option { + provenance::note( + self.basis.as_deref(), + self.confidence, + !self.evidence.is_empty(), + ) + } } /// First line of `s`, trimmed and capped — a stand-in description for a value @@ -464,9 +546,17 @@ pub struct Hit { /// everybody can. Weights: name 4, description 2, body 1, so a memory *about* /// the query outranks one that merely mentions it. /// -/// An empty query ranks everything equally, which makes recall degrade into -/// list — the right behaviour for a session-start hook that just wants what is -/// there. Ties break on `updated_at` descending, then key, so output is stable. +/// Relevance is then multiplied by the record's basis weight +/// ([`provenance::weight`]): a stated preference outranks an inference that +/// matches the query equally well, but a strong match on an inference still +/// beats a passing mention of a stated fact. `confidence` is never ranked on — +/// two authors' estimates are not on one scale. +/// +/// An empty query scores everything 0, which makes recall degrade into list — +/// the right behaviour for a session-start hook that just wants what is there. +/// Ties break on basis weight, then `updated_at` descending, then key, so what +/// the user stated survives a limit ahead of what was inferred, and output is +/// stable. pub fn rank(entries: Vec, query: &str) -> Vec { let terms: Vec = query .split(|c: char| !c.is_ascii_alphanumeric()) @@ -483,7 +573,7 @@ pub fn rank(entries: Vec, query: &str) -> Vec { let name = entry.record.name.to_ascii_lowercase(); let desc = entry.record.description.to_ascii_lowercase(); let body = entry.record.body.to_ascii_lowercase(); - terms + let relevance: u32 = terms .iter() .map(|t| { let mut s = 0; @@ -498,7 +588,8 @@ pub fn rank(entries: Vec, query: &str) -> Vec { } s }) - .sum() + .sum(); + relevance * entry.record.basis_weight() }; Hit { entry, score } }) @@ -507,6 +598,12 @@ pub fn rank(entries: Vec, query: &str) -> Vec { hits.sort_by(|a, b| { b.score .cmp(&a.score) + .then_with(|| { + b.entry + .record + .basis_weight() + .cmp(&a.entry.record.basis_weight()) + }) .then_with(|| b.entry.record.updated_at.cmp(&a.entry.record.updated_at)) .then_with(|| a.entry.key.to_string().cmp(&b.entry.key.to_string())) }); @@ -650,6 +747,115 @@ mod tests { assert_eq!(hits[0].score, 0); } + fn with_basis(mut e: Entry, basis: Basis, updated_at: &str) -> Entry { + e.record = e.record.with_provenance(basis, None, vec![]); + e.record.updated_at = updated_at.to_string(); + e + } + + #[test] + fn a_statement_outranks_an_equally_relevant_inference_even_a_newer_one() { + let stated = with_basis( + entry( + MemoryType::User, + "Short answers", + "prefers short answers", + "x", + ), + Basis::Declared, + "2026-01-01T00:00:00Z", + ); + let guessed = with_basis( + entry( + MemoryType::User, + "Short replies", + "prefers short answers", + "x", + ), + Basis::Inferred, + "2026-11-01T00:00:00Z", + ); + let hits = rank(vec![guessed, stated], "short answers"); + assert_eq!(hits[0].entry.record.name, "Short answers"); + assert!(hits[0].score > hits[1].score); + } + + #[test] + fn basis_is_a_nudge_not_an_override() { + // A memory *about* the query, inferred, still beats a stated memory + // that merely mentions it in passing. + let about = with_basis( + entry(MemoryType::Project, "Gateway", "the gateway rollout", "x"), + Basis::Inferred, + "2026-01-01T00:00:00Z", + ); + let mentions = with_basis( + entry( + MemoryType::Project, + "Other", + "something else", + "the gateway", + ), + Basis::Declared, + "2026-01-01T00:00:00Z", + ); + let hits = rank(vec![mentions, about], "gateway"); + assert_eq!(hits[0].entry.record.name, "Gateway"); + } + + #[test] + fn with_no_query_stated_memories_survive_a_limit_ahead_of_inferred_ones() { + let stated = with_basis( + entry(MemoryType::User, "A", "a", "a"), + Basis::Declared, + "2026-01-01T00:00:00Z", + ); + let guessed = with_basis( + entry(MemoryType::User, "B", "b", "b"), + Basis::Inferred, + "2026-11-01T00:00:00Z", + ); + let hits = rank(vec![guessed, stated], ""); + assert!(hits.iter().all(|h| h.score == 0)); + assert_eq!(hits[0].entry.record.name, "A"); + } + + #[test] + fn verified_without_evidence_ranks_as_an_inference() { + let mut unsupported = entry(MemoryType::User, "X", "x", "x"); + unsupported.record.basis = Some("verified".into()); + let inferred = with_basis(entry(MemoryType::User, "Y", "y", "y"), Basis::Inferred, ""); + assert_eq!( + unsupported.record.basis_weight(), + inferred.record.basis_weight() + ); + } + + #[test] + fn a_record_from_before_provenance_reads_as_unstated() { + let key = MemoryKey::parse("user/old").unwrap(); + let r = MemoryRecord::decode( + &key, + r#"{"v":1,"name":"Old","type":"user","description":"d","body":"b","updatedAt":""}"#, + ); + assert_eq!(r.basis(), None); + assert_eq!(r.summary(&key)["basis"], "unstated"); + assert_eq!(r.basis_note().as_deref(), Some("basis unstated")); + } + + #[test] + fn an_unknown_basis_from_a_later_writer_does_not_break_decoding() { + let key = MemoryKey::parse("user/new").unwrap(); + let r = MemoryRecord::decode( + &key, + r#"{"v":2,"name":"New","type":"user","description":"d","body":"b","updatedAt":"", + "basis":"testified","evidence":[{"kind":"notarised","ref":"x"}]}"#, + ); + assert_eq!(r.name, "New", "decoded as a record, not a foreign value"); + assert_eq!(r.basis(), None); + assert_eq!(r.full(&key)["evidence"][0]["kind"], "notarised"); + } + #[test] fn ranking_is_stable_for_equal_scores() { let a = entry(MemoryType::User, "A", "x", "x"); diff --git a/src/server.rs b/src/server.rs index b814202..c6063be 100644 --- a/src/server.rs +++ b/src/server.rs @@ -22,6 +22,7 @@ use serde::Deserialize; use crate::fence::{Fence, Provenance}; use crate::lazy::LazyStore; +use crate::provenance::{Basis, Evidence}; use crate::record::{MemoryKey, MemoryRecord, MemoryType}; use crate::store::Store; @@ -108,6 +109,21 @@ pub struct SaveParams { /// 120 characters. #[serde(default)] pub links: Vec, + /// What kind of claim this is. `declared` only when the user stated it + /// themselves; `observed` for something you saw happen (a command that + /// failed, a file that exists); `verified` only when you can cite what it + /// was checked against in `evidence`; otherwise `inferred` — your own + /// conclusion about the user or the work. When unsure, `inferred`. + pub basis: String, + /// Optional, for `inferred` or `observed`: your estimate from 0 to 1 that + /// this is correct. + #[serde(default)] + pub confidence: Option, + /// What this memory cites in support of itself — required for `verified`. + /// Each is an identifier, never fetched: a credential by id, another memory + /// by key, or a data-room record. At most 8. + #[serde(default)] + pub evidence: Vec, } #[derive(Debug, Deserialize, JsonSchema)] @@ -192,6 +208,9 @@ impl MemoryMcp { to external resources. Do NOT save what the repository already records — \ code structure, git history, past fixes — or anything that only matters to \ this conversation. Saving the same name twice replaces the earlier memory. \ + State the `basis` honestly: `declared` only for what the user said, \ + `inferred` for your own conclusions — inferred memories rank lower and are \ + shown as such in every later session. \ Refused if the name is over 120 characters, the description over 300, the \ body over 16 KiB, or there are more than 32 links or a link over 120 \ characters.", @@ -206,7 +225,17 @@ impl MemoryMcp { Parameters(p): Parameters, ) -> Result { let kind = parse_type(&p.kind)?; - let record = MemoryRecord::new(kind, &p.name, &p.description, &p.body, p.links); + let basis = Basis::parse(&p.basis).ok_or_else(|| { + McpError::invalid_params( + format!( + "unknown basis `{}`; expected declared, observed, verified or inferred", + p.basis + ), + None, + ) + })?; + let record = MemoryRecord::new(kind, &p.name, &p.description, &p.body, p.links) + .with_provenance(basis, p.confidence, p.evidence); // Before connecting: an oversized save is the caller's mistake, and // saying so must not depend on the VTA being reachable. record @@ -698,9 +727,47 @@ mod tests { description: description.to_string(), body: body.to_string(), links, + basis: "declared".to_string(), + confidence: None, + evidence: vec![], } } + #[tokio::test] + async fn provenance_is_checked_before_the_store() { + // As with the size limits: a malformed claim is the caller's mistake, + // and saying so must not depend on the VTA being reachable. + let mcp = unconfigured_server(); + let with = |basis: &str, confidence: Option| SaveParams { + basis: basis.to_string(), + confidence, + ..save_params("n", "d", "b", vec![]) + }; + for (what, params) in [ + ("unknown basis", with("hearsay", None)), + ("must cite", with("verified", None)), + ("between 0 and 1", with("inferred", Some(1.5))), + ] { + let err = mcp.memory_save(Parameters(params)).await.unwrap_err(); + assert_eq!(err.code, ErrorCode::INVALID_PARAMS, "{what}: {err:?}"); + assert!(err.message.contains(what), "{what}: {}", err.message); + } + } + + #[test] + fn the_save_schema_requires_a_basis() { + let schema = serde_json::to_value(schemars::schema_for!(SaveParams)).unwrap(); + let required: Vec<&str> = schema["required"] + .as_array() + .unwrap() + .iter() + .filter_map(Value::as_str) + .collect(); + assert!(required.contains(&"basis"), "{required:?}"); + assert!(!required.contains(&"confidence")); + assert!(!required.contains(&"evidence")); + } + #[tokio::test] async fn an_oversized_save_is_invalid_params_and_never_reaches_the_store() { // No config exists, so a save that got as far as the store would fail diff --git a/tests/memory_roundtrip.rs b/tests/memory_roundtrip.rs index 264a4a1..ee3c2ed 100644 --- a/tests/memory_roundtrip.rs +++ b/tests/memory_roundtrip.rs @@ -986,3 +986,121 @@ async fn a_store_without_a_signer_refuses_to_save() { assert!(format!("{err:#}").contains("no signing key"), "{err:#}"); assert!(vta.recorded().is_empty()); } + +// ---- #32: epistemic provenance --------------------------------------------- + +/// Basis, confidence and evidence are stored inside the signed record and come +/// back on recall and get. +#[tokio::test] +async fn provenance_round_trips_and_is_shown_on_recall_and_get() { + use vta_agent_memory::provenance::{Basis, Evidence}; + + let (store, vta) = store_with("proj"); + let key = store + .save( + &record( + MemoryType::User, + "Short answers", + "prefers short answers", + "x", + ) + .with_provenance( + Basis::Inferred, + Some(0.6), + vec![Evidence::Memory { + key: "feedback/too-long".into(), + }], + ), + ) + .await + .unwrap(); + + let stored: Value = serde_json::from_str(&vta.value("proj", "user/short-answers")).unwrap(); + assert_eq!(stored["basis"], "inferred"); + assert_eq!(stored["confidence"], 0.6); + assert_eq!( + stored["evidence"], + json!([{ "kind": "memory", "key": "feedback/too-long" }]) + ); + + let hit = &store.recall("short", None, 8).await.unwrap().hits[0]; + let summary = hit.entry.summary(); + assert_eq!(summary["basis"], "inferred"); + assert_eq!(summary["confidence"], 0.6); + assert!(summary.get("evidence").is_none(), "citations come with get"); + assert_eq!(summary["author"]["status"], "verified"); + + let full = store.get(&key).await.unwrap().unwrap().full(); + assert_eq!(full["evidence"][0]["key"], "feedback/too-long"); +} + +/// The basis is signed with the record: upgrading an inference to a statement +/// after the fact is tampering, and is refused. +#[tokio::test] +async fn upgrading_the_basis_after_signing_is_refused() { + use vta_agent_memory::provenance::Basis; + + let (store, vta) = store_with("proj"); + store + .save( + &record( + MemoryType::User, + "Prefers terse", + "prefers terse replies", + "x", + ) + .with_provenance(Basis::Inferred, Some(0.3), vec![]), + ) + .await + .unwrap(); + + let mut v: Value = serde_json::from_str(&vta.value("proj", "user/prefers-terse")).unwrap(); + v["basis"] = json!("declared"); + v.as_object_mut().unwrap().remove("confidence"); + vta.plant("proj", "user/prefers-terse", v.to_string()); + + let recall = store.recall("terse", None, 8).await.unwrap(); + assert!(recall.hits.is_empty()); + assert_eq!(recall.refused.len(), 1); +} + +/// On recall, a stated preference outranks an equally relevant inference — +/// end to end, through signing and verification. +#[tokio::test] +async fn recall_prefers_what_the_user_said_over_what_was_inferred() { + use vta_agent_memory::provenance::Basis; + + let (store, _vta) = store_with("proj"); + store + .save( + &record( + MemoryType::User, + "Tabs guess", + "uses tabs for indentation", + "x", + ) + .with_provenance(Basis::Inferred, Some(0.9), vec![]), + ) + .await + .unwrap(); + store + .save( + &record( + MemoryType::User, + "Tabs said", + "uses tabs for indentation", + "x", + ) + .with_provenance(Basis::Declared, None, vec![]), + ) + .await + .unwrap(); + + let hits = store + .recall("tabs indentation", None, 8) + .await + .unwrap() + .hits; + assert_eq!(hits[0].entry.record.name, "Tabs said"); + assert_eq!(hits[1].entry.record.name, "Tabs guess"); +}