Skip to content

Commit 38f53aa

Browse files
authored
feat: follow-rate efficacy scoring for memory entities (PMB-inspired) (#332)
Add mimir_follow MCP tool to track whether an agent actually FOLLOWED or MISSED a stored convention/insight/lesson, distinct from retrieval_count (which only measures recall frequency, not behavior change). This is the 'honest follow-rate' signal from PMB (pmbai.dev). - Schema v2->v3: entities gain follow_count, miss_count, follow_rate, efficacy_status columns. - New tool mimir_follow(category, key, followed, context?) records a follow/miss and recomputes follow_rate. After >=5 attempts, efficacy_status flips to 'useful' (rate >= 0.75) or 'dead' (rate < 0.20); otherwise stays 'unverified'. - decay_tick now applies an efficacy-weighted composite: 'useful' entities resist decay (weight 1.0 + follow_rate*0.3), 'dead' entities collapse toward the archive floor (weight 0.05). Verified/ curated entities remain exempt via the existing floor. - 3 new unit tests; full suite still 123 passed / 0 failed. - Version bump 2.9.0 -> 2.10.0.
1 parent 2b5eee2 commit 38f53aa

8 files changed

Lines changed: 449 additions & 19 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "perseus-vault"
3-
version = "2.9.0"
3+
version = "2.10.0"
44
edition = "2021"
55
description = "Persistent memory engine for AI agents — MCP JSON-RPC stdio server (formerly Mneme/Mimir)"
66
repository = "https://github.com/Perseus-Computing-LLC/perseus-vault"

src/db.rs

Lines changed: 289 additions & 15 deletions
Large diffs are not rendered by default.

src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,10 @@ fn main() {
773773
visibility: visibility.clone(),
774774
created_at_unix_ms: now,
775775
last_accessed_unix_ms: now,
776+
follow_count: 0,
777+
miss_count: 0,
778+
follow_rate: 0.0,
779+
efficacy_status: "unverified".to_string(),
776780
embedding: None,
777781
};
778782

src/mcp.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1809,6 +1809,68 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
18091809
},
18101810
"title": "Score Entity Quality"
18111811
},
1812+
{
1813+
"name": "mimir_follow",
1814+
"description": "Record whether an entity (typically a convention/insight/lesson) was actually FOLLOWED or MISSED by the agent — the honest follow-rate signal. Unlike retrieval_count (how often a memory is recalled), this tracks whether recall changed behavior. After enough attempts, efficacy_status flips to 'useful' or 'dead' and feeds into decay scoring so ignored rules decay out of recall while followed ones resist decay.",
1815+
"inputSchema": {
1816+
"type": "object",
1817+
"properties": {
1818+
"category": {
1819+
"type": "string",
1820+
"description": "Entity category"
1821+
},
1822+
"key": {
1823+
"type": "string",
1824+
"description": "Entity key"
1825+
},
1826+
"followed": {
1827+
"type": "boolean",
1828+
"description": "true if the agent's action followed/honored this entity's guidance, false if it was ignored/missed"
1829+
},
1830+
"context": {
1831+
"type": "string",
1832+
"description": "Optional description of the action/context this observation relates to"
1833+
}
1834+
},
1835+
"required": [
1836+
"category",
1837+
"key",
1838+
"followed"
1839+
]
1840+
},
1841+
"outputSchema": {
1842+
"type": "object",
1843+
"properties": {
1844+
"found": {
1845+
"type": "boolean",
1846+
"description": "Whether the entity was found"
1847+
},
1848+
"category": {
1849+
"type": "string"
1850+
},
1851+
"key": {
1852+
"type": "string"
1853+
},
1854+
"follow_count": {
1855+
"type": "integer"
1856+
},
1857+
"miss_count": {
1858+
"type": "integer"
1859+
},
1860+
"follow_rate": {
1861+
"type": "number"
1862+
},
1863+
"efficacy_status": {
1864+
"type": "string",
1865+
"description": "'unverified' | 'useful' | 'dead'"
1866+
}
1867+
}
1868+
},
1869+
"annotations": {
1870+
"destructiveHint": true
1871+
},
1872+
"title": "Record Follow/Miss Efficacy Signal"
1873+
},
18121874
{
18131875
"name": "mimir_conflicts",
18141876
"description": "Detect conflicting entities in the same category — pairs with low trigram similarity in their body_json. Flags potential contradictions, duplicate-but-divergent entries, and stale-overwritten facts. Read-only by default. Opt in with resolve=true to actively invalidate the lower-certainty side of clear conflicts (superseding it into history, reversible + time-travelable via mimir_as_of); that path defaults to dry_run=true so you preview first, and never resolves pairs whose certainties are within certainty_margin.",
@@ -2859,6 +2921,7 @@ fn call_tool(name: &str, db: &Database, args: Value, _id: Option<Value>) -> Stri
28592921

28602922
"mimir_traverse" => Ok(tools::handle_traverse(db, args)),
28612923
"mimir_score" => Ok(tools::handle_score(db, args)),
2924+
"mimir_follow" => tools::handle_follow(db, args).map_err(|e| e.to_string()),
28622925
"mimir_conflicts" => Ok(tools::handle_conflicts(db, args)),
28632926
"mimir_consolidate" => Ok(tools::handle_consolidate(db, args)),
28642927
"mimir_vault_export" => Ok(tools::handle_vault_export(db, args)),

src/models.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ pub struct Entity {
5151
pub visibility: String,
5252
pub created_at_unix_ms: i64,
5353
pub last_accessed_unix_ms: i64,
54+
/// Efficacy tracking (v2.10.0 — PMB-inspired follow-rate scoring). How many
55+
/// times this entity (typically a convention/insight/lesson) was confirmed
56+
/// or auto-detected as actually FOLLOWED vs missed by the agent.
57+
#[serde(default)]
58+
pub follow_count: i64,
59+
#[serde(default)]
60+
pub miss_count: i64,
61+
/// follow_count / (follow_count + miss_count); 0.0 when no attempts yet.
62+
#[serde(default)]
63+
pub follow_rate: f64,
64+
/// 'unverified' | 'useful' | 'dead' — set once enough attempts accrue.
65+
#[serde(default = "default_efficacy_status")]
66+
pub efficacy_status: String,
5467
#[serde(skip)]
5568
#[allow(dead_code)]
5669
pub embedding: Option<Vec<f32>>,
@@ -99,6 +112,10 @@ fn default_certainty() -> f64 {
99112
0.5
100113
}
101114

115+
fn default_efficacy_status() -> String {
116+
"unverified".to_string()
117+
}
118+
102119
/// Default recall trust weight. Non-zero so verified sources are preferred
103120
/// over unverified AI drafts everywhere by default; kept low so it acts as a
104121
/// tie-breaker rather than overriding relevance/recency.
@@ -340,6 +357,19 @@ pub struct DecayReport {
340357
pub completed_at_unix_ms: i64,
341358
}
342359

360+
/// Result of recording a follow/miss observation against an entity
361+
/// (v2.10.0 — PMB-inspired efficacy scoring).
362+
#[derive(Debug, Clone, Serialize)]
363+
pub struct FollowReport {
364+
pub found: bool,
365+
pub category: String,
366+
pub key: String,
367+
pub follow_count: i64,
368+
pub miss_count: i64,
369+
pub follow_rate: f64,
370+
pub efficacy_status: String,
371+
}
372+
343373
/// Compact report.
344374
#[derive(Debug, Clone, Serialize)]
345375
pub struct CompactReport {

src/schema.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,17 @@ CREATE TABLE IF NOT EXISTS entities (
4040
recorded_at_unix_ms INTEGER, -- transaction time: when Mneme first knew it (backfilled = created_at)
4141
invalidated_at_unix_ms INTEGER, -- transaction time: when Mneme retired it (NULL = live)
4242
supersedes TEXT DEFAULT '', -- id of the entity this one replaced
43-
superseded_by TEXT DEFAULT '' -- id of the entity that replaced this one
43+
superseded_by TEXT DEFAULT '', -- id of the entity that replaced this one
44+
45+
-- Efficacy tracking (v2.10.0 — PMB-inspired follow-rate scoring). Tracks
46+
-- whether a lesson/convention/insight actually gets FOLLOWED by the agent,
47+
-- not just recalled. follow_rate feeds into decay_tick as a composite
48+
-- weight so rules that get ignored decay out of recall, and rules that
49+
-- earn their place resist decay even without recency.
50+
follow_count INTEGER DEFAULT 0, -- times confirmed/detected as followed
51+
miss_count INTEGER DEFAULT 0, -- times confirmed/detected as NOT followed
52+
follow_rate REAL DEFAULT 0.0, -- follow_count / (follow_count + miss_count), 0.0 if no attempts
53+
efficacy_status TEXT DEFAULT 'unverified' -- 'unverified' | 'useful' | 'dead'
4454
);
4555
4656
CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_category_key ON entities(category, key);
@@ -127,7 +137,7 @@ CREATE INDEX IF NOT EXISTS idx_entity_history_catkey ON entity_history(category,
127137
/// the column-add migrations below have been applied. Bump this whenever you add
128138
/// a new ALTER-probe migration in `initialize_schema`, or existing databases
129139
/// (already at the previous level) will skip it.
130-
const SCHEMA_VERSION: i64 = 2;
140+
const SCHEMA_VERSION: i64 = 3;
131141

132142
/// Initialize the v0.2.0 schema on a fresh database.
133143
pub fn initialize_schema(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
@@ -221,6 +231,22 @@ pub fn initialize_schema(conn: &Connection) -> Result<(), Box<dyn std::error::Er
221231
if conn.prepare("SELECT superseded_by FROM entities LIMIT 1").is_err() {
222232
conn.execute_batch("ALTER TABLE entities ADD COLUMN superseded_by TEXT DEFAULT '';")?;
223233
}
234+
235+
// Add efficacy-tracking columns (v2.10.0 — PMB-inspired follow-rate scoring).
236+
if conn.prepare("SELECT follow_count FROM entities LIMIT 1").is_err() {
237+
conn.execute_batch("ALTER TABLE entities ADD COLUMN follow_count INTEGER DEFAULT 0;")?;
238+
}
239+
if conn.prepare("SELECT miss_count FROM entities LIMIT 1").is_err() {
240+
conn.execute_batch("ALTER TABLE entities ADD COLUMN miss_count INTEGER DEFAULT 0;")?;
241+
}
242+
if conn.prepare("SELECT follow_rate FROM entities LIMIT 1").is_err() {
243+
conn.execute_batch("ALTER TABLE entities ADD COLUMN follow_rate REAL DEFAULT 0.0;")?;
244+
}
245+
if conn.prepare("SELECT efficacy_status FROM entities LIMIT 1").is_err() {
246+
conn.execute_batch(
247+
"ALTER TABLE entities ADD COLUMN efficacy_status TEXT DEFAULT 'unverified';",
248+
)?;
249+
}
224250
// Backfill transaction time for pre-existing rows: a fact's recorded_at is
225251
// when Mneme first stored it, i.e. its created_at. (No-op on a fresh DB.)
226252
conn.execute_batch(

src/tools.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,10 @@ pub fn handle_remember(db: &Database, args: Value) -> Result<String, String> {
368368
visibility: a.visibility.clone(),
369369
created_at_unix_ms: now,
370370
last_accessed_unix_ms: now,
371+
follow_count: 0,
372+
miss_count: 0,
373+
follow_rate: 0.0,
374+
efficacy_status: "unverified".to_string(),
371375
embedding: None,
372376
};
373377

@@ -1150,6 +1154,31 @@ pub fn handle_score(db: &Database, args: Value) -> String {
11501154
}
11511155
}
11521156

1157+
#[derive(Debug, Deserialize)]
1158+
pub struct FollowArgs {
1159+
pub category: String,
1160+
pub key: String,
1161+
pub followed: bool,
1162+
#[serde(default)]
1163+
#[allow(dead_code)]
1164+
pub context: Option<String>,
1165+
}
1166+
1167+
/// Record whether an entity (convention/insight/lesson) was actually FOLLOWED
1168+
/// or MISSED by the agent — the PMB-inspired "honest follow-rate" signal.
1169+
/// `context` is accepted for future auto-detection/audit use but not yet
1170+
/// persisted; the tool records a manual confirm/deny each call.
1171+
pub fn handle_follow(db: &Database, args: Value) -> Result<String, String> {
1172+
let a: FollowArgs =
1173+
serde_json::from_value(args).map_err(|e| format!("Invalid follow arguments: {}", e))?;
1174+
1175+
let report = db
1176+
.follow(&a.category, &a.key, a.followed)
1177+
.map_err(|e| format!("Follow failed: {}", e))?;
1178+
1179+
serde_json::to_string(&report).map_err(|e| format!("Serialization failed: {}", e))
1180+
}
1181+
11531182
#[derive(Debug, Deserialize)]
11541183
pub struct ConflictArgs {
11551184
pub category: String,
@@ -1330,6 +1359,10 @@ pub fn handle_ingest_file(db: &Database, args: Value) -> Result<String, String>
13301359
visibility: "workspace".to_string(),
13311360
created_at_unix_ms: now,
13321361
last_accessed_unix_ms: now,
1362+
follow_count: 0,
1363+
miss_count: 0,
1364+
follow_rate: 0.0,
1365+
efficacy_status: "unverified".to_string(),
13331366
embedding: None,
13341367
};
13351368
let (eid, action) = db

0 commit comments

Comments
 (0)