Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/synapse/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "synapse"
# Bumping this on main is the release trigger. Keep it in step with
# crates/synapsecore, which is where `synapse version` reads its number from.
version = "1.13.0"
version = "1.14.0"
edition.workspace = true
description = "Local memory and scoped credentials for developer tools"

Expand Down
2 changes: 1 addition & 1 deletion crates/synapse/src/ui/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1638,7 +1638,7 @@ impl Dashboard {
Ok(()) => {
self.learnenabled = enabled;
self.notice = Notice::Success(match enabled {
true => "Self-improvement on. A skill an agent writes waits on the Skills page until you approve it.".to_owned(),
true => "Self-improvement on. Sessions can record outcomes and propose procedures for approval.".to_owned(),
false => "Self-improvement off. Skills already in the library stay where they are.".to_owned(),
});
self.refreshskills(cx);
Expand Down
2 changes: 1 addition & 1 deletion crates/synapse/src/ui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ fn learnpanel(
)
.child(
Text::new(
"Lets a session write down a procedure it worked out as a skill, and correct one that turned out wrong.",
"Lets sessions record outcomes, resolve drift, and propose procedures. Skill changes wait for approval.",
)
.size(Size::Xs)
.dimmed(),
Expand Down
2 changes: 1 addition & 1 deletion crates/synapsecore/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/synapsecore/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ name = "synapsecore"
# Keep this in step with crates/synapse: `synapse version` reads the version of
# the crate that holds the command, and the two ship as one product. Only the
# desktop crate's version triggers the release workflow.
version = "1.13.0"
version = "1.14.0"
edition = "2024"
description = "Memory, credentials, and the command surface for Synapse, without a window"

Expand Down
182 changes: 182 additions & 0 deletions crates/synapsecore/examples/learning.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//! on-demand learning cost in a disposable store, including full ledgers.

use anyhow::Result;
use serde_json::{Value, json};
use std::future::Future;
use std::time::Instant;
use synapsecore::brain::{Brain, MemoryScope, Optimization};
use synapsecore::learning::{Disposition, Feedback, Outcome, Target};

#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
let iterations: usize = std::env::args()
.nth(1)
.map(|n| n.parse())
.transpose()?
.unwrap_or(100);
anyhow::ensure!(
(1..=1_000).contains(&iterations),
"iterations must be 1..1000"
);
let directory = tempfile::tempdir()?;
let folder = directory.path().join("app");
std::fs::create_dir_all(folder.join(".git"))?;
let path = directory.path().join("brain.db");
let brain = Brain::open(&path).await?;
brain.setlearn(true).await?;
seed(&brain).await?;
let first = brain
.rememberscoped(
"Always squash commits when merging a pull request into main.",
None,
MemoryScope::Project,
Some(&folder),
)
.await?;
let second = brain
.rememberscoped(
"Never squash commits when merging a pull request into main.",
None,
MemoryScope::Project,
Some(&folder),
)
.await?;
let input = Feedback {
target: Target::Memory { id: first },
outcome: Outcome::Helpful,
problem: "Release branches require a documented merge procedure.".into(),
correction: "Check the branch policy and preserve reviewed history.".into(),
evidence: "The release verification confirmed the intended history.".into(),
query: String::new(),
expected: String::new(),
};
let record = brain.feedback(input.clone(), &folder, "benchmark").await?;
let mut measurements = Vec::new();
measurements.push(
measure("recall_with_unresolved_drift", iterations, || async {
let (_, memories) = brain
.recallscoped(
"squash commits merging",
4,
Some(Optimization::Lean),
Some(&folder),
)
.await?;
let findings = brain.drifted(&memories, Some(&folder)).await?;
Ok(serde_json::to_vec(&findings)?.len())
})
.await?,
);
let finding = brain.related(second, Some(&folder)).await?.remove(0);
let resolution = brain
.resolvefinding(
&folder,
&finding,
Disposition::Contextual,
"Different release branches use different policies.",
)
.await?;
let mut transaction = brain.pool().begin().await?;
for n in 0..4_999 {
sqlx::query("INSERT INTO learningfeedback(project,target,digest,lesson,session,outcome,problem,correction,evidence,created,updated) \
VALUES (?,?,?,?,?,'unresolved','fixture','','',CAST(strftime('%s','now') AS INTEGER),CAST(strftime('%s','now') AS INTEGER))")
.bind(&record.project).bind(&record.target).bind(&record.digest)
.bind(format!("fixture-{n}")).bind(format!("session-{n}"))
.execute(&mut *transaction).await?;
}
for n in 0..1_999 {
sqlx::query(
"INSERT INTO driftresolution(project,fingerprint,memoryid,disposition,note,created) \
VALUES (?,?,?,'falsepositive','fixture',CAST(strftime('%s','now') AS INTEGER))",
)
.bind(&record.project)
.bind(format!("fixture-{n}"))
.bind(finding.ids[0])
.execute(&mut *transaction)
.await?;
}
transaction.commit().await?;
measurements.push(
measure("recall_with_full_resolution_ledger", iterations, || async {
let (_, memories) = brain
.recallscoped(
"squash commits merging",
4,
Some(Optimization::Lean),
Some(&folder),
)
.await?;
let findings = brain.drifted(&memories, Some(&folder)).await?;
anyhow::ensure!(findings.is_empty(), "the saved assessment was lost");
Ok(serde_json::to_vec(&findings)?.len())
})
.await?,
);
measurements.push(
measure("feedback_with_full_ledgers", iterations, || async {
let updated = brain.feedback(input.clone(), &folder, "benchmark").await?;
Ok(serde_json::to_vec(&updated)?.len())
})
.await?,
);
brain.restoreresolution(resolution.id, &folder).await?;
measurements.push(
measure("confirm_with_full_ledgers", iterations, || async {
let updated = brain.confirmfeedback(record.id, &folder, true).await?;
Ok(serde_json::to_vec(&updated)?.len())
})
.await?,
);
let counts: (i64, i64) = sqlx::query_as(
"SELECT (SELECT COUNT(*) FROM learningfeedback),(SELECT COUNT(*) FROM driftresolution)",
)
.fetch_one(brain.pool())
.await?;
brain.pool().close().await;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"memories":10_002,"iterations":iterations,"feedback_rows":counts.0,
"resolution_rows":counts.1,"database_bytes":std::fs::metadata(&path)?.len(),
"measurements":measurements,
}))?
);
Ok(())
}

async fn measure<F, Fut>(name: &str, iterations: usize, mut work: F) -> Result<Value>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<usize>>,
{
work().await?;
let mut times = Vec::with_capacity(iterations);
let mut bytes = 0;
for _ in 0..iterations {
let start = Instant::now();
bytes = work().await?;
times.push(start.elapsed().as_secs_f64() * 1_000.0);
}
times.sort_by(f64::total_cmp);
Ok(json!({"name":name,"p50_ms":times[iterations/2],
"p95_ms":times[(iterations*95/100).min(iterations-1)],"response_bytes":bytes}))
}

async fn seed(brain: &Brain) -> Result<()> {
let mut transaction = brain.pool().begin().await?;
for n in 0..10_000 {
sqlx::query("INSERT INTO memory(body,source,created) VALUES (?,'fixture',1700000000)")
.bind(format!(
"Service {n} validates database connection scheduling and storage durability."
))
.execute(&mut *transaction)
.await?;
}
sqlx::query("INSERT INTO memorymeta(memoryid,scope,project,native,created) SELECT rowid,'global','',1,CAST(created AS INTEGER) FROM memory")
.execute(&mut *transaction).await?;
sqlx::query("INSERT INTO memorygram(rowid,body,source) SELECT rowid,body,source FROM memory")
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(())
}
1 change: 1 addition & 0 deletions crates/synapsecore/src/brain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ pub use read::{MemoryPage, ReadRequest, ReadResponse};
pub use redact::{Redacted, redact};
pub use scope::{projectkey, projectroot};
pub use store::Brain;
pub(crate) use store::COLUMNS;
32 changes: 20 additions & 12 deletions crates/synapsecore/src/brain/drift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::brain::{Brain, Memory, MemoryScope};
use anyhow::{Context, Result};
use compare::{Disagreement, Profile, Relation};
use schemars::JsonSchema;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

Expand All @@ -45,7 +45,9 @@ const PROBE: usize = 16;
/// Characters of a counterpart quoted in a finding.
const QUOTED: usize = 200;

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, JsonSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum Concern {
/// Names a path that is gone.
Expand All @@ -57,13 +59,13 @@ pub enum Concern {
}

/// Ids are oldest first.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, JsonSchema)]
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
pub struct Finding {
pub concern: Concern,
pub ids: Vec<i64>,
pub note: String,
/// Paths a stale memory names that are gone.
#[serde(skip_serializing_if = "Vec::is_empty")]
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing: Vec<String>,
}

Expand Down Expand Up @@ -96,6 +98,9 @@ impl Brain {
let capped = memories.len() as i64 > SCANCEILING;
let memories = &memories[..memories.len().min(SCANCEILING as usize)];
let (findings, partial) = scanlimited(memories, &checkout);
let findings = self
.filterdrift(findings, &memories.iter().collect::<Vec<_>>(), folder, true)
.await?;
Ok(Drift {
root: checkout.root().map(Path::to_path_buf),
project,
Expand Down Expand Up @@ -161,7 +166,7 @@ impl Brain {
let Some(relation @ Relation::Conflict(_)) = compare::relate(&own, profile) else {
continue;
};
let mut finding = paired(memory, &other, relation);
let mut finding = paired(memory, other, relation);
finding.note.push_str(&format!(
" #{} is not in this response; it says: {}",
other.id,
Expand All @@ -174,7 +179,8 @@ impl Brain {
}
}
findings.sort_by(|a, b| a.concern.cmp(&b.concern).then(a.ids.cmp(&b.ids)));
Ok(findings)
let known = memories.iter().chain(recent.iter()).collect::<Vec<_>>();
self.filterdrift(findings, &known, folder, false).await
}

/// What a newly written memory contradicts or repeats.
Expand All @@ -190,14 +196,16 @@ impl Brain {
.flatten()
.unwrap_or_default(),
};
let mut findings: Vec<Finding> = self
.counterparts(&memory, &project)
.await?
.into_iter()
.map(|(other, relation)| paired(&memory, &other, relation))
let counterparts = self.counterparts(&memory, &project).await?;
let mut findings: Vec<Finding> = counterparts
.iter()
.map(|(other, relation)| paired(&memory, other, relation.clone()))
.collect();
findings.sort_by(|a, b| a.concern.cmp(&b.concern).then(a.ids.cmp(&b.ids)));
Ok(findings)
let known = std::iter::once(&memory)
.chain(counterparts.iter().map(|(other, _)| other))
.collect::<Vec<_>>();
self.filterdrift(findings, &known, folder, true).await
}

/// Visible memories related to `memory`, found by searching its own words.
Expand Down
14 changes: 14 additions & 0 deletions crates/synapsecore/src/brain/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ pub struct Scored {
pub shown: bool,
/// How many memories came back.
pub returned: usize,
/// Memory characters actually handed over, after the recall budget.
pub characters: usize,
/// Recall latency, excluding the evaluation's label lookup.
pub micros: u64,
}

/// The whole run.
Expand Down Expand Up @@ -106,9 +110,15 @@ impl Brain {
) -> Result<Evaluation> {
let mut scored = Vec::with_capacity(cases.len());
for case in cases {
let started = std::time::Instant::now();
let (_, recalled) = self
.recallscoped(&case.query, limit, budget, case.project.as_deref())
.await?;
let micros = started.elapsed().as_micros().min(u64::MAX as u128) as u64;
let characters = recalled
.iter()
.map(|memory| memory.body.chars().count())
.sum();
let expect: Vec<String> = case.expect.iter().map(|text| text.to_lowercase()).collect();
let mut rank = None;
let mut shown = false;
Expand All @@ -131,6 +141,8 @@ impl Brain {
rank,
shown,
returned: recalled.len(),
characters,
micros,
});
}
Ok(summarize(scored, limit))
Expand Down Expand Up @@ -233,6 +245,8 @@ mod tests {
rank,
shown: rank == Some(1),
returned,
characters: 0,
micros: 0,
};
let evaluation = summarize(
vec![
Expand Down
Loading
Loading