Compare commits
24 commits
0d1044c2e8
...
0592c5f78d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0592c5f78d | ||
|
|
4245b8bdb3 | ||
|
|
343aa12099 | ||
|
|
2e03bbb7ea | ||
|
|
b8714e8b3a | ||
|
|
50d5b3f6e1 | ||
|
|
d9f39a21c3 | ||
|
|
3622b896a0 | ||
|
|
8952ff6a76 | ||
|
|
c8976660f4 | ||
|
|
0f1c4cf1de | ||
|
|
047da10123 | ||
|
|
15737dfd92 | ||
|
|
34bd122590 | ||
|
|
ec7568c726 | ||
|
|
43e06daa5b | ||
|
|
d4331e80f5 | ||
|
|
2b03dbb200 | ||
|
|
575325e855 | ||
|
|
c5745e38e2 | ||
|
|
eea7de4753 | ||
|
88752e3c89 |
|||
|
e17c46edc1 |
|||
|
b23f6484e2 |
169 changed files with 2682 additions and 525 deletions
194
Cargo.lock
generated
194
Cargo.lock
generated
|
|
@ -372,6 +372,12 @@ dependencies = [
|
|||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
|
|
@ -453,6 +459,16 @@ version = "1.0.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.0"
|
||||
|
|
@ -488,6 +504,7 @@ dependencies = [
|
|||
"figment",
|
||||
"futures",
|
||||
"glob",
|
||||
"html2md",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
|
|
@ -1099,6 +1116,16 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||
dependencies = [
|
||||
"mac",
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
|
|
@ -1299,6 +1326,34 @@ version = "0.4.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "html2md"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4"
|
||||
dependencies = [
|
||||
"html5ever",
|
||||
"jni",
|
||||
"lazy_static",
|
||||
"markup5ever_rcdom",
|
||||
"percent-encoding",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
|
|
@ -1548,6 +1603,48 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
|
|
@ -1703,6 +1800,12 @@ dependencies = [
|
|||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mac_address"
|
||||
version = "1.1.8"
|
||||
|
|
@ -1729,6 +1832,32 @@ version = "0.2.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
|
||||
dependencies = [
|
||||
"log",
|
||||
"phf",
|
||||
"phf_codegen",
|
||||
"string_cache",
|
||||
"string_cache_codegen",
|
||||
"tendril",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever_rcdom"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18"
|
||||
dependencies = [
|
||||
"html5ever",
|
||||
"markup5ever",
|
||||
"tendril",
|
||||
"xml5ever",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
|
|
@ -1809,6 +1938,12 @@ dependencies = [
|
|||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "new_debug_unreachable"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
|
|
@ -2205,6 +2340,12 @@ dependencies = [
|
|||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
|
|
@ -2828,6 +2969,31 @@ version = "0.1.9"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"parking_lot",
|
||||
"phf_shared",
|
||||
"precomputed-hash",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache_codegen"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
|
@ -2917,6 +3083,17 @@ dependencies = [
|
|||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||
dependencies = [
|
||||
"futf",
|
||||
"mac",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "terminfo"
|
||||
version = "0.9.0"
|
||||
|
|
@ -3564,6 +3741,12 @@ version = "0.9.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "utf-8"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
|
|
@ -4144,6 +4327,17 @@ dependencies = [
|
|||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xml5ever"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69"
|
||||
dependencies = [
|
||||
"log",
|
||||
"mac",
|
||||
"markup5ever",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust"
|
||||
version = "0.4.5"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ edition.workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
html2md = "0.2"
|
||||
crossterm = { version = "0.29", features = ["event-stream", "bracketed-paste", "osc52"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
figment = { version = "0.10", features = ["env"] }
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ impl State {
|
|||
while i > 0 && !remaining.is_char_boundary(i) { i -= 1; }
|
||||
// To avoid splitting mid-word, see if there was a space recently
|
||||
let mut j = i;
|
||||
while j > 0 && j > i-10 && remaining.as_bytes()[j] != b' ' { j -= 1; }
|
||||
while j > 1 && j > i-10 && remaining.as_bytes()[j] != b' ' { j -= 1; }
|
||||
if remaining.as_bytes()[j] == b' ' { j }
|
||||
else if i == 0 { max_msg } else { i }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,6 +22,21 @@ pub struct Usage {
|
|||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// Concept-readout manifest returned by the vLLM server's
|
||||
/// `/v1/readout/manifest` endpoint. Maps the nameless tensor indices
|
||||
/// in streaming `readout` fields back to concept names and layer
|
||||
/// indices.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReadoutManifest {
|
||||
pub concepts: Vec<String>,
|
||||
pub layers: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Per-token per-layer concept projections streamed alongside each
|
||||
/// sampled token. Shape `[n_layers][n_concepts]`. Named values come
|
||||
/// from pairing with the manifest fetched at startup.
|
||||
pub type TokenReadout = Vec<Vec<f32>>;
|
||||
|
||||
/// A JoinHandle that aborts its task when dropped.
|
||||
pub(crate) struct AbortOnDrop(tokio::task::JoinHandle<()>);
|
||||
|
||||
|
|
@ -45,7 +60,10 @@ pub(crate) struct SamplingParams {
|
|||
|
||||
/// One token from the streaming completions API.
|
||||
pub enum StreamToken {
|
||||
Token(u32),
|
||||
/// A sampled token, optionally with its per-layer concept readout.
|
||||
/// `readout` is `None` when the server has readout disabled or
|
||||
/// returned no readout for this chunk.
|
||||
Token { id: u32, readout: Option<TokenReadout> },
|
||||
Done { usage: Option<Usage> },
|
||||
Error(String),
|
||||
}
|
||||
|
|
@ -73,15 +91,6 @@ impl ApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stream_completion(
|
||||
&self,
|
||||
prompt_tokens: &[u32],
|
||||
sampling: SamplingParams,
|
||||
priority: Option<i32>,
|
||||
) -> (mpsc::UnboundedReceiver<StreamToken>, AbortOnDrop) {
|
||||
self.stream_completion_mm(prompt_tokens, &[], sampling, priority)
|
||||
}
|
||||
|
||||
pub(crate) fn stream_completion_mm(
|
||||
&self,
|
||||
prompt_tokens: &[u32],
|
||||
|
|
@ -115,6 +124,32 @@ impl ApiClient {
|
|||
pub fn base_url(&self) -> &str { &self.base_url }
|
||||
pub fn api_key(&self) -> &str { &self.api_key }
|
||||
|
||||
/// Fetch `/v1/readout/manifest` — returns `Ok(Some(..))` if
|
||||
/// readout is enabled on the server, `Ok(None)` on 404 (disabled),
|
||||
/// or an error on any other failure.
|
||||
///
|
||||
/// Call once at startup and cache the result; the manifest doesn't
|
||||
/// change during a server run.
|
||||
pub async fn fetch_readout_manifest(&self) -> Result<Option<ReadoutManifest>> {
|
||||
let url = format!("{}/readout/manifest", self.base_url);
|
||||
let auth = format!("Bearer {}", self.api_key);
|
||||
let response = self
|
||||
.client
|
||||
.get_with_headers(&url, &[("Authorization", &auth)])
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("readout manifest fetch ({}): {}", url, e))?;
|
||||
let status = response.status();
|
||||
if status.as_u16() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let n = body.floor_char_boundary(body.len().min(500));
|
||||
anyhow::bail!("readout manifest HTTP {} ({}): {}", status, url, &body[..n]);
|
||||
}
|
||||
Ok(Some(response.json().await?))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async fn stream_completions(
|
||||
|
|
@ -181,17 +216,45 @@ async fn stream_completions(
|
|||
};
|
||||
|
||||
for choice in choices {
|
||||
// `readout`, if present, is a nested list
|
||||
// `[num_tokens][n_layers][n_concepts]`. Parse it once per
|
||||
// chunk and pair rows with token ids by index — the rows
|
||||
// are in the same order as `token_ids`.
|
||||
let readouts: Option<Vec<TokenReadout>> = choice["readout"]
|
||||
.as_array()
|
||||
.map(|outer| {
|
||||
outer.iter().filter_map(|per_token| {
|
||||
per_token.as_array().map(|layers| {
|
||||
layers.iter().filter_map(|per_layer| {
|
||||
per_layer.as_array().map(|vals| {
|
||||
vals.iter()
|
||||
.filter_map(|v| v.as_f64().map(|f| f as f32))
|
||||
.collect::<Vec<f32>>()
|
||||
})
|
||||
}).collect::<Vec<Vec<f32>>>()
|
||||
})
|
||||
}).collect()
|
||||
});
|
||||
|
||||
if let Some(ids) = choice["token_ids"].as_array() {
|
||||
for id_val in ids {
|
||||
for (i, id_val) in ids.iter().enumerate() {
|
||||
if let Some(id) = id_val.as_u64() {
|
||||
let _ = tx.send(StreamToken::Token(id as u32));
|
||||
let readout = readouts
|
||||
.as_ref()
|
||||
.and_then(|r| r.get(i).cloned());
|
||||
let _ = tx.send(StreamToken::Token {
|
||||
id: id as u32,
|
||||
readout,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if let Some(text) = choice["text"].as_str() {
|
||||
// Fallback: provider didn't return token_ids, encode locally
|
||||
// Fallback: provider didn't return token_ids, encode locally.
|
||||
// No readout available in this path — the encoder may
|
||||
// produce a different token count than the server did.
|
||||
if !text.is_empty() {
|
||||
for id in super::tokenizer::encode(text) {
|
||||
let _ = tx.send(StreamToken::Token(id));
|
||||
let _ = tx.send(StreamToken::Token { id, readout: None });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -682,7 +682,12 @@ impl ResponseParser {
|
|||
let mut full_text = String::new();
|
||||
while let Some(event) = stream.recv().await {
|
||||
match event {
|
||||
super::api::StreamToken::Token(id) => {
|
||||
super::api::StreamToken::Token { id, readout } => {
|
||||
if let Some(r) = readout {
|
||||
if let Ok(mut buf) = agent.readout.lock() {
|
||||
buf.push(id, r);
|
||||
}
|
||||
}
|
||||
let text = super::tokenizer::decode(&[id]);
|
||||
full_text.push_str(&text);
|
||||
let mut ctx = agent.context.lock().await;
|
||||
|
|
@ -920,19 +925,114 @@ fn wire_into(node: &AstNode, tokens: &mut Vec<u32>, images: &mut Vec<WireImage>)
|
|||
}
|
||||
}
|
||||
|
||||
pub fn memory_key(node: &AstNode) -> Option<&str> {
|
||||
match node {
|
||||
AstNode::Leaf(leaf) => match leaf.body() {
|
||||
NodeBody::Memory { key, .. } => Some(key),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_memory_node(node: &AstNode) -> bool {
|
||||
matches!(node, AstNode::Leaf(leaf) if matches!(leaf.body(), NodeBody::Memory { .. }))
|
||||
}
|
||||
|
||||
pub fn is_assistant(node: &AstNode) -> bool {
|
||||
matches!(node, AstNode::Branch { role: Role::Assistant, .. })
|
||||
}
|
||||
|
||||
/// Concatenate the text of a Branch's Leaf children — what the model
|
||||
/// actually produced on that turn (Content + Thinking + ToolCall name).
|
||||
pub fn render_branch_text(children: &[AstNode]) -> String {
|
||||
children.iter()
|
||||
.filter_map(|c| match c {
|
||||
AstNode::Leaf(leaf) => Some(leaf.body().text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Render the last `max_msgs` user/assistant branches before `idx` as a
|
||||
/// review-friendly string with `[user]` / `[assistant]` markers.
|
||||
pub fn render_prior_context(entries: &[AstNode], idx: usize, max_msgs: usize) -> String {
|
||||
let mut picked: Vec<&AstNode> = Vec::with_capacity(max_msgs);
|
||||
for i in (0..idx).rev() {
|
||||
if picked.len() >= max_msgs { break; }
|
||||
if let AstNode::Branch { role, .. } = &entries[i] {
|
||||
if matches!(role, Role::User | Role::Assistant) {
|
||||
picked.push(&entries[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
picked.reverse();
|
||||
|
||||
let mut out = String::new();
|
||||
for node in picked {
|
||||
if let AstNode::Branch { role, children, .. } = node {
|
||||
let marker = match role {
|
||||
Role::User => "[user]",
|
||||
Role::Assistant => "[assistant]",
|
||||
_ => continue,
|
||||
};
|
||||
out.push_str(marker);
|
||||
out.push('\n');
|
||||
out.push_str(render_branch_text(children).trim());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
|
||||
impl ContextState {
|
||||
/// Assemble the prompt in wire form: token stream with a single
|
||||
/// `<|image_pad|>` per image (vLLM expands back to N), plus the list
|
||||
/// of images to send as multi_modal_data.
|
||||
pub fn wire_prompt(&self) -> (Vec<u32>, Vec<WireImage>) {
|
||||
/// of images to send as multi_modal_data, plus the (start, end) token
|
||||
/// positions of each assistant message branch emitted (used by the
|
||||
/// scoring path as `score_ranges`).
|
||||
///
|
||||
/// `conv_range` selects a prefix (or any sub-range) of conversation
|
||||
/// entries to include — the agent path passes `0..conversation().len()`;
|
||||
/// scoring / candidate generation pass a prefix up to the entry of
|
||||
/// interest.
|
||||
///
|
||||
/// `skip` is a predicate applied to identity and conversation entries;
|
||||
/// returning true drops the node from the prompt. The agent path passes
|
||||
/// `|_| false`; memory-ablation scoring passes e.g. `is_memory_node` or
|
||||
/// `|n| memory_key(n) == Some(key)`.
|
||||
pub fn wire_prompt<F>(
|
||||
&self,
|
||||
conv_range: std::ops::Range<usize>,
|
||||
mut skip: F,
|
||||
) -> (Vec<u32>, Vec<WireImage>, Vec<(usize, usize)>)
|
||||
where F: FnMut(&AstNode) -> bool,
|
||||
{
|
||||
let mut tokens = Vec::new();
|
||||
let mut images = Vec::new();
|
||||
for section in self.sections() {
|
||||
for node in section {
|
||||
let mut assistant_ranges = Vec::new();
|
||||
|
||||
for node in self.system() {
|
||||
wire_into(node, &mut tokens, &mut images);
|
||||
}
|
||||
for node in self.identity() {
|
||||
if skip(node) { continue; }
|
||||
wire_into(node, &mut tokens, &mut images);
|
||||
}
|
||||
(tokens, images)
|
||||
for node in self.journal() {
|
||||
wire_into(node, &mut tokens, &mut images);
|
||||
}
|
||||
for node in &self.conversation()[conv_range] {
|
||||
if skip(node) { continue; }
|
||||
let start = tokens.len();
|
||||
let is_asst = matches!(node, AstNode::Branch { role: Role::Assistant, .. });
|
||||
wire_into(node, &mut tokens, &mut images);
|
||||
if is_asst {
|
||||
assistant_ranges.push((start, tokens.len()));
|
||||
}
|
||||
}
|
||||
(tokens, images, assistant_ranges)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1598,7 +1698,7 @@ mod tests {
|
|||
assert_eq!(n_image_pads_full, qwen3_image_token_count(512, 512) as usize);
|
||||
|
||||
// Wire side: single image_pad, bytes moved to images list.
|
||||
let (wire, images) = ctx.wire_prompt();
|
||||
let (wire, images, _) = ctx.wire_prompt(0..ctx.conversation().len(), |_| false);
|
||||
let n_image_pads_wire = wire.iter()
|
||||
.filter(|&&t| t == tokenizer::IMAGE_PAD).count();
|
||||
assert_eq!(n_image_pads_wire, 1);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
pub mod api;
|
||||
pub mod context;
|
||||
pub mod oneshot;
|
||||
pub mod readout;
|
||||
pub mod tokenizer;
|
||||
pub mod tools;
|
||||
|
||||
|
|
@ -142,6 +143,11 @@ pub struct Agent {
|
|||
pub session_id: String,
|
||||
pub context: crate::Mutex<ContextState>,
|
||||
pub state: crate::Mutex<AgentState>,
|
||||
/// Shared landing pad for per-token concept-readout projections
|
||||
/// streamed from the vLLM server. Populated by the streaming
|
||||
/// token handler, read by UI screens (amygdala). Manifest is
|
||||
/// `None` when the server has readout disabled.
|
||||
pub readout: readout::SharedReadoutBuffer,
|
||||
}
|
||||
|
||||
/// Mutable agent state — behind its own mutex.
|
||||
|
|
@ -172,7 +178,6 @@ pub struct AgentState {
|
|||
pub pending_dmn_pause: bool,
|
||||
pub provenance: String,
|
||||
pub generation: u64,
|
||||
pub memory_scoring_in_flight: bool,
|
||||
pub active_tools: tools::ActiveTools,
|
||||
/// vLLM scheduling priority (lower = higher priority).
|
||||
/// 0 = interactive, 1 = surface agent, 2 = other subconscious, 10 = unconscious.
|
||||
|
|
@ -215,11 +220,13 @@ impl Agent {
|
|||
}
|
||||
|
||||
let session_id = format!("consciousness-{}", chrono::Utc::now().format("%Y%m%d-%H%M%S"));
|
||||
let readout = readout::new_shared();
|
||||
let agent = Arc::new(Self {
|
||||
client,
|
||||
app_config,
|
||||
session_id,
|
||||
context: crate::Mutex::new(context),
|
||||
readout,
|
||||
state: crate::Mutex::new(AgentState {
|
||||
tools: agent_tools,
|
||||
mcp_tools: McpToolAccess::All,
|
||||
|
|
@ -237,7 +244,6 @@ impl Agent {
|
|||
pending_dmn_pause: false,
|
||||
provenance: "manual".to_string(),
|
||||
generation: 0,
|
||||
memory_scoring_in_flight: false,
|
||||
active_tools,
|
||||
priority: Some(0),
|
||||
no_compact: false,
|
||||
|
|
@ -246,6 +252,32 @@ impl Agent {
|
|||
});
|
||||
|
||||
agent.load_startup_journal().await;
|
||||
|
||||
// Probe the vLLM server for its readout manifest. Non-fatal:
|
||||
// if readout isn't enabled the server returns 404 and we
|
||||
// leave the manifest as None, which disables the amygdala
|
||||
// screen gracefully.
|
||||
match agent.client.fetch_readout_manifest().await {
|
||||
Ok(Some(m)) => {
|
||||
dbglog!(
|
||||
"readout manifest: {} concepts, layers={:?}",
|
||||
m.concepts.len(),
|
||||
m.layers,
|
||||
);
|
||||
if let Ok(mut buf) = agent.readout.lock() {
|
||||
buf.set_manifest(Some(m));
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
dbglog!(
|
||||
"readout manifest: server has readout disabled (404)"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
dbglog!("readout manifest fetch failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
agent
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +290,12 @@ impl Agent {
|
|||
app_config: self.app_config.clone(),
|
||||
session_id: self.session_id.clone(),
|
||||
context: crate::Mutex::new(ctx),
|
||||
// Forks get an independent readout buffer. The amygdala
|
||||
// screen reads the main conscious agent's buffer only;
|
||||
// subconscious generations (scoring, reflection, etc.)
|
||||
// shouldn't bleed into the main emotional readout even
|
||||
// though they hit the same vLLM server.
|
||||
readout: readout::new_shared(),
|
||||
state: crate::Mutex::new(AgentState {
|
||||
tools,
|
||||
mcp_tools: McpToolAccess::None,
|
||||
|
|
@ -275,7 +313,6 @@ impl Agent {
|
|||
pending_dmn_pause: false,
|
||||
provenance: st.provenance.clone(),
|
||||
generation: 0,
|
||||
memory_scoring_in_flight: false,
|
||||
active_tools: tools::ActiveTools::new(),
|
||||
priority: None,
|
||||
no_compact: true,
|
||||
|
|
@ -294,7 +331,8 @@ impl Agent {
|
|||
pub async fn assemble_prompt(&self) -> (Vec<u32>, Vec<context::WireImage>) {
|
||||
let ctx = self.context.lock().await;
|
||||
let st = self.state.lock().await;
|
||||
let (mut tokens, images) = ctx.wire_prompt();
|
||||
let (mut tokens, images, _) =
|
||||
ctx.wire_prompt(0..ctx.conversation().len(), |_| false);
|
||||
tokens.push(tokenizer::IM_START);
|
||||
if st.think_native {
|
||||
tokens.extend(tokenizer::encode("assistant\n<think>\n"));
|
||||
|
|
|
|||
75
src/agent/readout.rs
Normal file
75
src/agent/readout.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// agent/readout.rs — live buffer of concept-readout projections.
|
||||
//
|
||||
// The vLLM server projects residual-stream activations onto a fixed
|
||||
// matrix of concept directions during each decode step and ships the
|
||||
// result back on every streamed chunk (see
|
||||
// vllm/docs/features/readout.md). This module owns the client-side
|
||||
// landing pad: a ring of the last N token projections plus the
|
||||
// concept/layer mapping fetched from `/v1/readout/manifest` at
|
||||
// startup.
|
||||
//
|
||||
// Readers (UI screens) lock briefly, read a snapshot, release. Writers
|
||||
// (the streaming token handler) push one entry per token. Intentionally
|
||||
// a simple Mutex<VecDeque> rather than lock-free — the UI ticks at
|
||||
// ~15 Hz and the stream at token-rate, contention is nil.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::api::{ReadoutManifest, TokenReadout};
|
||||
|
||||
/// Default ring length — at ~30 tok/s this is ~6 seconds of history,
|
||||
/// enough for the amygdala screen's scrolling display.
|
||||
const DEFAULT_RING_LEN: usize = 200;
|
||||
|
||||
/// One entry in the readout ring: the sampled token and its per-layer
|
||||
/// concept projection vector.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReadoutEntry {
|
||||
pub token_id: u32,
|
||||
/// Shape `[n_layers][n_concepts]`.
|
||||
pub readout: TokenReadout,
|
||||
}
|
||||
|
||||
/// Shared buffer of recent per-token concept projections plus the
|
||||
/// manifest that names the layer/concept indices. `manifest` is `None`
|
||||
/// when the server has readout disabled or the fetch failed — callers
|
||||
/// should treat that as "readout unavailable" and skip rendering.
|
||||
#[derive(Default)]
|
||||
pub struct ReadoutBuffer {
|
||||
pub manifest: Option<ReadoutManifest>,
|
||||
pub recent: VecDeque<ReadoutEntry>,
|
||||
pub max_len: usize,
|
||||
}
|
||||
|
||||
impl ReadoutBuffer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
manifest: None,
|
||||
recent: VecDeque::with_capacity(DEFAULT_RING_LEN),
|
||||
max_len: DEFAULT_RING_LEN,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_manifest(&mut self, manifest: Option<ReadoutManifest>) {
|
||||
self.manifest = manifest;
|
||||
}
|
||||
|
||||
pub fn push(&mut self, token_id: u32, readout: TokenReadout) {
|
||||
if self.recent.len() >= self.max_len {
|
||||
self.recent.pop_front();
|
||||
}
|
||||
self.recent.push_back(ReadoutEntry { token_id, readout });
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.manifest.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread-safe handle.
|
||||
pub type SharedReadoutBuffer = Arc<Mutex<ReadoutBuffer>>;
|
||||
|
||||
pub fn new_shared() -> SharedReadoutBuffer {
|
||||
Arc::new(Mutex::new(ReadoutBuffer::new()))
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ use std::sync::Arc;
|
|||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
use html2md::parse_html;
|
||||
|
||||
pub fn tools() -> [super::Tool; 2] {
|
||||
[
|
||||
|
|
@ -42,7 +43,9 @@ async fn web_fetch(args: &serde_json::Value) -> Result<String> {
|
|||
let body = response.text().await
|
||||
.with_context(|| format!("failed to read body from {}", a.url))?;
|
||||
|
||||
Ok(super::truncate_output(body, 30000))
|
||||
// Convert HTML to Markdown, then truncate
|
||||
let markdown = parse_html(&body);
|
||||
Ok(super::truncate_output(markdown, 30000))
|
||||
}
|
||||
|
||||
// ── Search ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -250,6 +250,8 @@ pub struct AppConfig {
|
|||
#[serde(default)]
|
||||
pub learn: LearnConfig,
|
||||
#[serde(default)]
|
||||
pub compare: CompareConfig,
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<McpServerConfig>,
|
||||
#[serde(default)]
|
||||
pub lsp_servers: Vec<LspServerConfig>,
|
||||
|
|
@ -323,6 +325,16 @@ impl Default for LearnConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Settings for the F7 compare screen — side-by-side generation with a
|
||||
/// test model against the current context.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct CompareConfig {
|
||||
/// Backend name (looked up in `backends`) to use as the test model.
|
||||
/// Empty = F7 reports "no test backend configured" and does nothing.
|
||||
#[serde(default)]
|
||||
pub test_backend: String,
|
||||
}
|
||||
|
||||
fn default_user_name() -> String { "User".into() }
|
||||
fn default_assistant_name() -> String { "Assistant".into() }
|
||||
|
||||
|
|
@ -340,6 +352,7 @@ impl Default for AppConfig {
|
|||
},
|
||||
dmn: DmnConfig { max_turns: 20 },
|
||||
learn: LearnConfig::default(),
|
||||
compare: CompareConfig::default(),
|
||||
mcp_servers: Vec::new(),
|
||||
lsp_servers: Vec::new(),
|
||||
}
|
||||
|
|
|
|||
309
src/mind/mod.rs
309
src/mind/mod.rs
|
|
@ -9,6 +9,44 @@ pub mod unconscious;
|
|||
pub mod identity;
|
||||
pub mod log;
|
||||
|
||||
/// A background operation wired off Mind. Each flow (memory scoring,
|
||||
/// finetune scoring, compare) is a struct holding its dependencies and
|
||||
/// a TaskHandle; `trigger()` picks the flow's own "start a fresh run"
|
||||
/// semantics (abort-restart vs no-op-if-running).
|
||||
pub trait MindTriggered {
|
||||
fn trigger(&self);
|
||||
}
|
||||
|
||||
/// Owns a JoinHandle for a background task with two trigger semantics.
|
||||
/// Uses a sync Mutex for interior mutability so callers can `trigger()`
|
||||
/// off `&self` (Mind is shared via Arc).
|
||||
#[derive(Default)]
|
||||
pub struct TaskHandle(std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>);
|
||||
|
||||
impl TaskHandle {
|
||||
pub fn new() -> Self { Self::default() }
|
||||
|
||||
/// Abort any running task and start a fresh one.
|
||||
pub fn trigger<F>(&self, fut: F)
|
||||
where F: std::future::Future<Output = ()> + Send + 'static
|
||||
{
|
||||
let mut h = self.0.lock().unwrap();
|
||||
if let Some(old) = h.take() { old.abort(); }
|
||||
*h = Some(tokio::spawn(fut));
|
||||
}
|
||||
|
||||
/// No-op if a task is still running; otherwise start a fresh one.
|
||||
pub fn trigger_if_idle<F>(&self, fut: F)
|
||||
where F: std::future::Future<Output = ()> + Send + 'static
|
||||
{
|
||||
let mut h = self.0.lock().unwrap();
|
||||
if let Some(old) = &*h {
|
||||
if !old.is_finished() { return; }
|
||||
}
|
||||
*h = Some(tokio::spawn(fut));
|
||||
}
|
||||
}
|
||||
|
||||
// consciousness.rs — Mind state machine and event loop
|
||||
//
|
||||
// The core runtime for the consciousness binary. Mind manages turns,
|
||||
|
|
@ -25,7 +63,7 @@ use tokio::sync::mpsc;
|
|||
use crate::agent::{Agent, TurnResult};
|
||||
use crate::agent::api::ApiClient;
|
||||
use crate::config::{AppConfig, SessionConfig};
|
||||
use crate::subconscious::learn;
|
||||
use crate::subconscious::{compare, learn};
|
||||
use crate::hippocampus::access_local;
|
||||
|
||||
pub use subconscious::{SubconsciousSnapshot, Subconscious};
|
||||
|
|
@ -48,7 +86,7 @@ fn match_scores(
|
|||
}).collect()
|
||||
}
|
||||
|
||||
fn find_memory_by_key(ctx: &ContextState, key: &str) -> Option<(Section, usize)> {
|
||||
pub(crate) fn find_memory_by_key(ctx: &ContextState, key: &str) -> Option<(Section, usize)> {
|
||||
[(Section::Identity, ctx.identity()), (Section::Conversation, ctx.conversation())]
|
||||
.into_iter()
|
||||
.find_map(|(section, nodes)| {
|
||||
|
|
@ -87,7 +125,7 @@ fn load_memory_scores(ctx: &mut ContextState, path: &std::path::Path) {
|
|||
}
|
||||
|
||||
/// Collect scored memory keys from identity and conversation entries.
|
||||
fn collect_memory_scores(ctx: &ContextState) -> std::collections::BTreeMap<String, f64> {
|
||||
pub(crate) fn collect_memory_scores(ctx: &ContextState) -> std::collections::BTreeMap<String, f64> {
|
||||
ctx.identity().iter()
|
||||
.chain(ctx.conversation().iter())
|
||||
.filter_map(|node| {
|
||||
|
|
@ -102,7 +140,7 @@ fn collect_memory_scores(ctx: &ContextState) -> std::collections::BTreeMap<Strin
|
|||
}
|
||||
|
||||
/// Save memory scores to disk.
|
||||
fn save_memory_scores(scores: &std::collections::BTreeMap<String, f64>, path: &std::path::Path) {
|
||||
pub(crate) fn save_memory_scores(scores: &std::collections::BTreeMap<String, f64>, path: &std::path::Path) {
|
||||
match serde_json::to_string_pretty(scores) {
|
||||
Ok(json) => match std::fs::write(path, &json) {
|
||||
Ok(()) => dbglog!("[scoring] saved {} scores to {} ({} bytes)",
|
||||
|
|
@ -154,22 +192,12 @@ pub struct MindState {
|
|||
/// Fine-tuning candidates identified by scoring.
|
||||
pub finetune_candidates: Vec<learn::FinetuneCandidate>,
|
||||
/// Last scoring run stats for UI display.
|
||||
pub finetune_last_run: Option<FinetuneScoringStats>,
|
||||
}
|
||||
|
||||
/// Stats from the last finetune scoring run.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FinetuneScoringStats {
|
||||
/// Count of assistant responses we considered (recent half of context).
|
||||
pub responses_considered: usize,
|
||||
/// How many exceeded the divergence threshold.
|
||||
pub above_threshold: usize,
|
||||
/// Threshold used for this run.
|
||||
pub threshold: f64,
|
||||
/// Highest divergence observed.
|
||||
pub max_divergence: f64,
|
||||
/// Error message if the run failed.
|
||||
pub error: Option<String>,
|
||||
pub finetune_last_run: Option<learn::FinetuneScoringStats>,
|
||||
/// F7 compare candidates — one per response, showing what the test
|
||||
/// model would say given the same context.
|
||||
pub compare_candidates: Vec<compare::CompareCandidate>,
|
||||
/// F7 compare error from the last run, if any.
|
||||
pub compare_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Clone for MindState {
|
||||
|
|
@ -190,6 +218,8 @@ impl Clone for MindState {
|
|||
unc_idle_deadline: self.unc_idle_deadline,
|
||||
finetune_candidates: self.finetune_candidates.clone(),
|
||||
finetune_last_run: self.finetune_last_run.clone(),
|
||||
compare_candidates: self.compare_candidates.clone(),
|
||||
compare_error: self.compare_error.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -204,6 +234,9 @@ pub enum MindCommand {
|
|||
ScoreFull,
|
||||
/// Score for finetune candidates
|
||||
ScoreFinetune,
|
||||
/// Run F7 compare: generate alternates with the configured test model
|
||||
/// for every assistant response in the context.
|
||||
Compare,
|
||||
/// Update the finetune divergence threshold and persist to config.
|
||||
SetLearnThreshold(f64),
|
||||
/// Toggle alternate-response generation during scoring; persist to config.
|
||||
|
|
@ -235,6 +268,8 @@ impl MindState {
|
|||
unc_idle_deadline: Instant::now() + std::time::Duration::from_secs(60),
|
||||
finetune_candidates: Vec::new(),
|
||||
finetune_last_run: None,
|
||||
compare_candidates: Vec::new(),
|
||||
compare_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -291,7 +326,7 @@ impl MindState {
|
|||
}
|
||||
|
||||
/// DMN tick — returns a prompt and target if we should run a turn.
|
||||
fn dmn_tick(&mut self) -> Option<(String, StreamTarget)> {
|
||||
fn _dmn_tick(&mut self) -> Option<(String, StreamTarget)> {
|
||||
if matches!(self.dmn, subconscious::State::Paused | subconscious::State::Off) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -318,11 +353,6 @@ impl MindState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Background task completion events.
|
||||
enum BgEvent {
|
||||
ScoringDone,
|
||||
FinetuneCandidate(learn::FinetuneCandidate),
|
||||
}
|
||||
|
||||
// --- Mind: cognitive state machine ---
|
||||
|
||||
|
|
@ -339,8 +369,9 @@ pub struct Mind {
|
|||
/// Signals conscious activity to the unconscious loop.
|
||||
/// true = active, false = idle opportunity.
|
||||
conscious_active: tokio::sync::watch::Sender<bool>,
|
||||
bg_tx: mpsc::UnboundedSender<BgEvent>,
|
||||
bg_rx: std::sync::Mutex<Option<mpsc::UnboundedReceiver<BgEvent>>>,
|
||||
memory_scoring: learn::MemoryScoring,
|
||||
finetune_scoring: learn::FinetuneScoring,
|
||||
compare_scoring: compare::CompareScoring,
|
||||
_supervisor: crate::thalamus::supervisor::Supervisor,
|
||||
}
|
||||
|
||||
|
|
@ -380,7 +411,6 @@ impl Mind {
|
|||
)));
|
||||
let (turn_watch, _) = tokio::sync::watch::channel(false);
|
||||
let (conscious_active, _) = tokio::sync::watch::channel(false);
|
||||
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let mut sup = crate::thalamus::supervisor::Supervisor::new();
|
||||
sup.load_config();
|
||||
|
|
@ -465,10 +495,19 @@ impl Mind {
|
|||
});
|
||||
}
|
||||
|
||||
let scores_path = config.session_dir.join("memory-scores.json");
|
||||
let memory_scoring = learn::MemoryScoring::new(
|
||||
agent.clone(), shared.clone(), scores_path);
|
||||
let finetune_scoring = learn::FinetuneScoring::new(agent.clone(), shared.clone());
|
||||
let compare_scoring = compare::CompareScoring::new(agent.clone(), shared.clone());
|
||||
|
||||
Self { agent, shared, config,
|
||||
subconscious, unconscious,
|
||||
turn_tx, turn_watch, conscious_active, bg_tx,
|
||||
bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup }
|
||||
turn_tx, turn_watch, conscious_active,
|
||||
memory_scoring,
|
||||
finetune_scoring,
|
||||
compare_scoring,
|
||||
_supervisor: sup }
|
||||
}
|
||||
|
||||
/// Initialize — restore log, start daemons and background agents.
|
||||
|
|
@ -513,14 +552,7 @@ impl Mind {
|
|||
|
||||
// Kick off an incremental scoring pass on startup so memories due
|
||||
// for re-scoring get evaluated without requiring a user message.
|
||||
{
|
||||
let mut s = self.shared.lock().unwrap();
|
||||
if !s.scoring_in_flight {
|
||||
s.scoring_in_flight = true;
|
||||
drop(s);
|
||||
self.start_memory_scoring();
|
||||
}
|
||||
}
|
||||
self.memory_scoring.trigger();
|
||||
}
|
||||
|
||||
pub fn turn_watch(&self) -> tokio::sync::watch::Receiver<bool> {
|
||||
|
|
@ -540,24 +572,10 @@ impl Mind {
|
|||
}
|
||||
}
|
||||
MindCommand::Score => {
|
||||
let mut s = self.shared.lock().unwrap();
|
||||
if !s.scoring_in_flight {
|
||||
s.scoring_in_flight = true;
|
||||
drop(s);
|
||||
self.start_memory_scoring();
|
||||
} else {
|
||||
dbglog!("[scoring] skipped: scoring_in_flight=true");
|
||||
}
|
||||
self.memory_scoring.trigger();
|
||||
}
|
||||
MindCommand::ScoreFull => {
|
||||
let mut s = self.shared.lock().unwrap();
|
||||
if !s.scoring_in_flight {
|
||||
s.scoring_in_flight = true;
|
||||
drop(s);
|
||||
self.start_full_scoring();
|
||||
} else {
|
||||
dbglog!("[scoring-full] skipped: scoring_in_flight=true");
|
||||
}
|
||||
self.memory_scoring.trigger_full();
|
||||
}
|
||||
MindCommand::Interrupt => {
|
||||
self.shared.lock().unwrap().interrupt();
|
||||
|
|
@ -588,7 +606,10 @@ impl Mind {
|
|||
self.agent.compact().await;
|
||||
}
|
||||
MindCommand::ScoreFinetune => {
|
||||
self.start_finetune_scoring();
|
||||
self.finetune_scoring.trigger();
|
||||
}
|
||||
MindCommand::Compare => {
|
||||
self.compare_scoring.trigger();
|
||||
}
|
||||
MindCommand::SetLearnThreshold(value) => {
|
||||
if let Err(e) = crate::config_writer::set_learn_threshold(value) {
|
||||
|
|
@ -605,167 +626,6 @@ impl Mind {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn start_memory_scoring(&self) {
|
||||
let agent = self.agent.clone();
|
||||
let bg_tx = self.bg_tx.clone();
|
||||
let scores_path = self.config.session_dir.join("memory-scores.json");
|
||||
let cfg = crate::config::get();
|
||||
let max_age = cfg.scoring_interval_secs;
|
||||
let response_window = cfg.scoring_response_window;
|
||||
tokio::spawn(async move {
|
||||
let (context, client) = {
|
||||
let mut st = agent.state.lock().await;
|
||||
if st.memory_scoring_in_flight {
|
||||
dbglog!("[scoring] skipped: memory_scoring_in_flight=true");
|
||||
return;
|
||||
}
|
||||
st.memory_scoring_in_flight = true;
|
||||
drop(st);
|
||||
let ctx = agent.context.lock().await.clone();
|
||||
(ctx, agent.client.clone())
|
||||
};
|
||||
let _result = learn::score_memories_incremental(
|
||||
&context, max_age as i64, response_window, &client, &agent,
|
||||
|key: String, score: f64| {
|
||||
let agent = agent.clone();
|
||||
let path = scores_path.clone();
|
||||
async move {
|
||||
let scores_snapshot = {
|
||||
let mut ctx = agent.context.lock().await;
|
||||
// Find memory by key in identity or conversation
|
||||
let found = find_memory_by_key(&ctx, &key);
|
||||
match found {
|
||||
Some((section, i)) => {
|
||||
ctx.set_score(section, i, Some(score));
|
||||
let nodes: &[crate::agent::context::AstNode] = match section {
|
||||
Section::Identity => ctx.identity(),
|
||||
Section::Conversation => ctx.conversation(),
|
||||
_ => &[],
|
||||
};
|
||||
let read_back = match nodes.get(i) {
|
||||
Some(crate::agent::context::AstNode::Leaf(l)) => match l.body() {
|
||||
crate::agent::context::NodeBody::Memory { score, .. } => format!("{:?}", score),
|
||||
_ => "not-memory".to_string(),
|
||||
},
|
||||
_ => "out-of-bounds".to_string(),
|
||||
};
|
||||
dbglog!("[scoring] persisted {} → {:.3} ({:?}[{}]) read_back={}",
|
||||
key, score, section, i, read_back);
|
||||
}
|
||||
None => {
|
||||
dbglog!(
|
||||
"[scoring] DROP {}: find_memory_by_key None (id={}, cv={})",
|
||||
key, ctx.identity().len(), ctx.conversation().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
let snapshot = collect_memory_scores(&ctx);
|
||||
let in_snapshot = snapshot.contains_key(&key);
|
||||
dbglog!("[scoring] snapshot size={} contains({})={}",
|
||||
snapshot.len(), key, in_snapshot);
|
||||
drop(ctx);
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
snapshot
|
||||
};
|
||||
dbglog!("[scoring] about to save {} entries", scores_snapshot.len());
|
||||
save_memory_scores(&scores_snapshot, &path);
|
||||
}
|
||||
},
|
||||
).await;
|
||||
{
|
||||
agent.state.lock().await.memory_scoring_in_flight = false;
|
||||
}
|
||||
let _ = bg_tx.send(BgEvent::ScoringDone);
|
||||
});
|
||||
}
|
||||
|
||||
/// Run full N×M scoring matrix — scores every memory against every response.
|
||||
pub fn start_full_scoring(&self) {
|
||||
let agent = self.agent.clone();
|
||||
let bg_tx = self.bg_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
{
|
||||
let mut st = agent.state.lock().await;
|
||||
if st.memory_scoring_in_flight {
|
||||
dbglog!("[scoring-full] skipped: memory_scoring_in_flight=true");
|
||||
return;
|
||||
}
|
||||
st.memory_scoring_in_flight = true;
|
||||
}
|
||||
let client = agent.client.clone();
|
||||
match learn::score_memories(&client, &agent).await {
|
||||
Ok(()) => { let _ = bg_tx.send(BgEvent::ScoringDone); }
|
||||
Err(e) => { dbglog!("[scoring-full] FAILED: {:#}", e); }
|
||||
}
|
||||
agent.state.lock().await.memory_scoring_in_flight = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Score responses for fine-tuning candidates.
|
||||
///
|
||||
/// Scores the most recent half of the context — responses near the end
|
||||
/// of the context window were generated with the most context available,
|
||||
/// which is what we want to train on. The threshold is a temporary knob;
|
||||
/// once this runs continuously, we'll just train whatever lands at full
|
||||
/// context without filtering.
|
||||
pub fn start_finetune_scoring(&self) {
|
||||
// Snapshot the config values we need before spawning — the scoring
|
||||
// task shouldn't hold the config read lock across async work.
|
||||
let (threshold, gen_alternates) = {
|
||||
let app = crate::config::app();
|
||||
(app.learn.threshold, app.learn.generate_alternates)
|
||||
};
|
||||
// Clear the previous run's candidates so this run's stream is fresh.
|
||||
self.shared.lock().unwrap().finetune_candidates.clear();
|
||||
|
||||
let agent = self.agent.clone();
|
||||
let bg_tx = self.bg_tx.clone();
|
||||
let shared = self.shared.clone();
|
||||
tokio::spawn(async move {
|
||||
let activity = crate::agent::start_activity(&agent, "finetune: scoring...").await;
|
||||
|
||||
let (context, client) = {
|
||||
let ctx = agent.context.lock().await;
|
||||
(ctx.clone(), agent.client.clone())
|
||||
};
|
||||
|
||||
let entries = context.conversation();
|
||||
let score_count = entries.len() / 2;
|
||||
let range_start = entries.len() - score_count;
|
||||
let responses_considered: usize = entries[range_start..].iter()
|
||||
.filter(|n| matches!(n, crate::agent::context::AstNode::Branch { role: crate::agent::context::Role::Assistant, .. }))
|
||||
.count();
|
||||
|
||||
activity.update(format!("finetune: scoring {} responses...", responses_considered)).await;
|
||||
|
||||
let bg_tx_cb = bg_tx.clone();
|
||||
let stats = match learn::score_finetune_candidates(
|
||||
&context, score_count, &client, threshold,
|
||||
gen_alternates, &activity,
|
||||
|c| { let _ = bg_tx_cb.send(BgEvent::FinetuneCandidate(c)); },
|
||||
).await {
|
||||
Ok((above_threshold, max_div)) => {
|
||||
FinetuneScoringStats {
|
||||
responses_considered,
|
||||
above_threshold,
|
||||
threshold,
|
||||
max_divergence: max_div,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => FinetuneScoringStats {
|
||||
responses_considered,
|
||||
above_threshold: 0,
|
||||
threshold,
|
||||
max_divergence: 0.0,
|
||||
error: Some(format!("{}", e)),
|
||||
},
|
||||
};
|
||||
|
||||
shared.lock().unwrap().finetune_last_run = Some(stats);
|
||||
// activity drops here, marking completion and notifying observers
|
||||
});
|
||||
}
|
||||
|
||||
async fn start_turn(&self, text: &str, target: StreamTarget) {
|
||||
{
|
||||
|
|
@ -828,13 +688,11 @@ impl Mind {
|
|||
}
|
||||
});
|
||||
|
||||
let mut bg_rx = self.bg_rx.lock().unwrap().take()
|
||||
.expect("Mind::run() called twice");
|
||||
let mut sub_handle: Option<tokio::task::JoinHandle<()>> = None;
|
||||
|
||||
// Start finetune scoring at startup (scores existing conversation)
|
||||
if !self.config.no_agents {
|
||||
self.start_finetune_scoring();
|
||||
self.finetune_scoring.trigger();
|
||||
}
|
||||
|
||||
loop {
|
||||
|
|
@ -857,17 +715,6 @@ impl Mind {
|
|||
}
|
||||
}
|
||||
|
||||
Some(bg) = bg_rx.recv() => {
|
||||
match bg {
|
||||
BgEvent::ScoringDone => {
|
||||
self.shared.lock().unwrap().scoring_in_flight = false;
|
||||
}
|
||||
BgEvent::FinetuneCandidate(c) => {
|
||||
self.shared.lock().unwrap().finetune_candidates.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some((result, target)) = turn_rx.recv() => {
|
||||
let _ = self.conscious_active.send(false);
|
||||
let model_switch = {
|
||||
|
|
|
|||
109
src/subconscious/compare.rs
Normal file
109
src/subconscious/compare.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// compare.rs — F7 compare: for each assistant response in the current
|
||||
// context, regenerate with a configured test model and emit pairs for
|
||||
// side-by-side review.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::agent::api::ApiClient;
|
||||
use crate::agent::context::{
|
||||
AstNode, Role, render_branch_text, render_prior_context,
|
||||
};
|
||||
use crate::mind::{MindState, MindTriggered, TaskHandle};
|
||||
use crate::subconscious::generate::gen_continuation;
|
||||
use crate::subconscious::learn::node_timestamp_ns;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CompareCandidate {
|
||||
pub entry_idx: usize,
|
||||
pub original_text: String,
|
||||
pub alternate_text: String,
|
||||
pub prior_context: String,
|
||||
pub timestamp_ns: i64,
|
||||
}
|
||||
|
||||
pub struct CompareScoring {
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
task: TaskHandle,
|
||||
}
|
||||
|
||||
impl CompareScoring {
|
||||
pub fn new(
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
) -> Self {
|
||||
Self { agent, shared, task: TaskHandle::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl MindTriggered for CompareScoring {
|
||||
fn trigger(&self) {
|
||||
self.task.trigger(run(self.agent.clone(), self.shared.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_test_client() -> Result<ApiClient, String> {
|
||||
let cfg = crate::config::app();
|
||||
let name = cfg.compare.test_backend.clone();
|
||||
if name.is_empty() {
|
||||
return Err("compare.test_backend not set in config".to_string());
|
||||
}
|
||||
let r = cfg.resolve_model(&name).map_err(|e| format!("{:#}", e))?;
|
||||
Ok(ApiClient::new(&r.api_base, &r.api_key, &r.model_id))
|
||||
}
|
||||
|
||||
async fn run(
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
) {
|
||||
{
|
||||
let mut s = shared.lock().unwrap();
|
||||
s.compare_candidates.clear();
|
||||
s.compare_error = None;
|
||||
}
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
|
||||
let activity = crate::agent::start_activity(&agent, "compare: scoring...").await;
|
||||
|
||||
let test_client = match resolve_test_client() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
shared.lock().unwrap().compare_error = Some(e);
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let context = agent.context.lock().await.clone();
|
||||
let entries = context.conversation();
|
||||
let responses: Vec<usize> = entries.iter().enumerate()
|
||||
.filter(|(_, n)| matches!(n, AstNode::Branch { role: Role::Assistant, .. }))
|
||||
.map(|(i, _)| i).collect();
|
||||
|
||||
for (i, entry_idx) in responses.iter().copied().enumerate() {
|
||||
activity.update(format!("compare: {}/{}", i + 1, responses.len())).await;
|
||||
|
||||
let node = &entries[entry_idx];
|
||||
let original_text = match node {
|
||||
AstNode::Branch { children, .. } => render_branch_text(children),
|
||||
_ => continue,
|
||||
};
|
||||
if original_text.trim().is_empty() { continue; }
|
||||
|
||||
let alternate_text = match
|
||||
gen_continuation(&context, entry_idx, |_| false, &test_client).await
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => { dbglog!("[compare] gen failed at {}: {:#}", entry_idx, e); continue; }
|
||||
};
|
||||
|
||||
shared.lock().unwrap().compare_candidates.push(CompareCandidate {
|
||||
entry_idx,
|
||||
original_text,
|
||||
alternate_text,
|
||||
prior_context: render_prior_context(entries, entry_idx, 2),
|
||||
timestamp_ns: node_timestamp_ns(node),
|
||||
});
|
||||
if let Ok(st) = agent.state.try_lock() { st.changed.notify_one(); }
|
||||
}
|
||||
}
|
||||
46
src/subconscious/generate.rs
Normal file
46
src/subconscious/generate.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// generate.rs — Continuation generation for scoring / comparison flows.
|
||||
//
|
||||
// Shared by the finetune pipeline (learn.rs) and the compare screen:
|
||||
// given a context prefix and a skip predicate, generate what the model
|
||||
// would say as the next assistant turn.
|
||||
|
||||
use crate::agent::api::{ApiClient, SamplingParams, StreamToken};
|
||||
use crate::agent::context::{AstNode, ContextState};
|
||||
use crate::agent::tokenizer;
|
||||
|
||||
/// Generate an assistant continuation from the context up to `entry_idx`,
|
||||
/// with `skip` applied to identity + conversation entries during prompt
|
||||
/// assembly. The model is whichever `client` points at — the default
|
||||
/// runtime client for memory-ablation alternates, a test-model client
|
||||
/// for F7 comparison.
|
||||
pub async fn gen_continuation<F>(
|
||||
context: &ContextState,
|
||||
entry_idx: usize,
|
||||
skip: F,
|
||||
client: &ApiClient,
|
||||
) -> anyhow::Result<String>
|
||||
where F: FnMut(&AstNode) -> bool,
|
||||
{
|
||||
let (mut prompt, images, _) = context.wire_prompt(0..entry_idx, skip);
|
||||
|
||||
prompt.push(tokenizer::IM_START);
|
||||
prompt.extend(tokenizer::encode("assistant\n"));
|
||||
|
||||
let sampling = SamplingParams {
|
||||
temperature: 0.6,
|
||||
top_p: 0.95,
|
||||
top_k: 20,
|
||||
};
|
||||
let (mut rx, _guard) = client.stream_completion_mm(&prompt, &images, sampling, Some(-5));
|
||||
|
||||
let mut tokens = Vec::new();
|
||||
while let Some(tok) = rx.recv().await {
|
||||
match tok {
|
||||
StreamToken::Token { id, .. } => tokens.push(id),
|
||||
StreamToken::Done { .. } => break,
|
||||
StreamToken::Error(e) => anyhow::bail!("generation error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(tokenizer::decode(&tokens))
|
||||
}
|
||||
|
|
@ -14,96 +14,18 @@
|
|||
// with high divergence depend on memories the model
|
||||
// hasn't internalized. 2 API calls.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::agent::api::ApiClient;
|
||||
use crate::agent::context::{AstNode, Ast, NodeBody, ContextState, Role};
|
||||
use crate::agent::tokenizer;
|
||||
use crate::agent::context::{
|
||||
Ast, AstNode, ContextState, Role, WireImage,
|
||||
is_assistant, is_memory_node, memory_key, render_branch_text, render_prior_context,
|
||||
};
|
||||
use crate::mind::{MindState, MindTriggered, TaskHandle};
|
||||
use crate::subconscious::generate::gen_continuation;
|
||||
|
||||
const SCORE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
|
||||
|
||||
// ── Message building ────────────────────────────────────────────
|
||||
|
||||
/// What to filter when building the message array for scoring.
|
||||
#[allow(dead_code)]
|
||||
enum Filter<'a> {
|
||||
None,
|
||||
SkipIndex(usize),
|
||||
SkipKey(&'a str),
|
||||
SkipAllMemories,
|
||||
}
|
||||
|
||||
fn is_memory(node: &AstNode) -> bool {
|
||||
matches!(node, AstNode::Leaf(leaf) if matches!(leaf.body(), NodeBody::Memory { .. }))
|
||||
}
|
||||
|
||||
fn memory_key(node: &AstNode) -> Option<&str> {
|
||||
match node {
|
||||
AstNode::Leaf(leaf) => match leaf.body() {
|
||||
NodeBody::Memory { key, .. } => Some(key),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_assistant(node: &AstNode) -> bool {
|
||||
matches!(node, AstNode::Branch { role: Role::Assistant, .. })
|
||||
}
|
||||
|
||||
/// Build a token ID array for a scoring call.
|
||||
///
|
||||
/// Includes all sections up to and including conversation entries in
|
||||
/// `range`, with `filter` applied to conversation entries.
|
||||
///
|
||||
/// Returns (token_ids, assistant_ranges) where assistant_ranges are
|
||||
/// (start, end) token positions for each assistant message.
|
||||
fn build_token_ids(
|
||||
context: &ContextState,
|
||||
range: std::ops::Range<usize>,
|
||||
filter: Filter,
|
||||
) -> (Vec<u32>, Vec<(usize, usize)>) {
|
||||
use crate::agent::context::Ast;
|
||||
let mut ids = Vec::new();
|
||||
let mut assistant_ranges = Vec::new();
|
||||
|
||||
for node in context.system() {
|
||||
ids.extend(node.token_ids());
|
||||
}
|
||||
// Identity nodes can be filtered by key for scoring
|
||||
for node in context.identity() {
|
||||
let skip = match &filter {
|
||||
Filter::SkipKey(key) => memory_key(node) == Some(*key),
|
||||
Filter::SkipAllMemories => is_memory(node),
|
||||
_ => false,
|
||||
};
|
||||
if !skip {
|
||||
ids.extend(node.token_ids());
|
||||
}
|
||||
}
|
||||
for node in context.journal() {
|
||||
ids.extend(node.token_ids());
|
||||
}
|
||||
let entries = context.conversation();
|
||||
for i in range {
|
||||
let node = &entries[i];
|
||||
let skip = match &filter {
|
||||
Filter::None => false,
|
||||
Filter::SkipIndex(idx) => i == *idx,
|
||||
Filter::SkipKey(key) => memory_key(node) == Some(*key),
|
||||
Filter::SkipAllMemories => is_memory(node),
|
||||
};
|
||||
if skip { continue; }
|
||||
|
||||
// Track assistant message boundaries
|
||||
let is_asst = is_assistant(node);
|
||||
let start = ids.len();
|
||||
ids.extend(node.token_ids());
|
||||
if is_asst {
|
||||
assistant_ranges.push((start, ids.len()));
|
||||
}
|
||||
}
|
||||
(ids, assistant_ranges)
|
||||
}
|
||||
|
||||
// ── Score API ───────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
|
|
@ -126,6 +48,7 @@ async fn call_score(
|
|||
http: &crate::agent::api::http::HttpClient,
|
||||
client: &ApiClient,
|
||||
prompt: &[u32],
|
||||
images: &[WireImage],
|
||||
ranges: &[(usize, usize)],
|
||||
priority: Option<i32>,
|
||||
) -> anyhow::Result<Vec<ScoreResult>> {
|
||||
|
|
@ -141,6 +64,14 @@ async fn call_score(
|
|||
"score_ranges": ranges,
|
||||
"logprobs": 1,
|
||||
});
|
||||
if !images.is_empty() {
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let uris: Vec<String> = images.iter()
|
||||
.map(|img| format!("data:{};base64,{}", img.mime, b64.encode(&img.bytes)))
|
||||
.collect();
|
||||
body["multi_modal_data"] = serde_json::json!({ "image": uris });
|
||||
}
|
||||
if let Some(p) = priority {
|
||||
body["priority"] = serde_json::json!(p);
|
||||
}
|
||||
|
|
@ -178,18 +109,24 @@ fn divergence(baseline: &[ScoreResult], without: &[ScoreResult]) -> Vec<f64> {
|
|||
}
|
||||
|
||||
/// Score two message sets and return total divergence.
|
||||
async fn score_divergence(
|
||||
async fn score_divergence<F>(
|
||||
http: &crate::agent::api::http::HttpClient,
|
||||
client: &ApiClient,
|
||||
context: &ContextState,
|
||||
range: std::ops::Range<usize>,
|
||||
filter: Filter<'_>,
|
||||
skip: F,
|
||||
priority: Option<i32>,
|
||||
) -> anyhow::Result<(Vec<f64>, Vec<ScoreResult>)> {
|
||||
let (baseline_tokens, baseline_ranges) = build_token_ids(context, range.clone(), Filter::None);
|
||||
let (without_tokens, without_ranges) = build_token_ids(context, range, filter);
|
||||
let baseline = call_score(http, client, &baseline_tokens, &baseline_ranges, priority).await?;
|
||||
let without = call_score(http, client, &without_tokens, &without_ranges, priority).await?;
|
||||
) -> anyhow::Result<(Vec<f64>, Vec<ScoreResult>)>
|
||||
where F: FnMut(&AstNode) -> bool,
|
||||
{
|
||||
let (baseline_tokens, baseline_images, baseline_ranges) =
|
||||
context.wire_prompt(range.clone(), |_| false);
|
||||
let (without_tokens, without_images, without_ranges) =
|
||||
context.wire_prompt(range, skip);
|
||||
let baseline = call_score(http, client, &baseline_tokens, &baseline_images,
|
||||
&baseline_ranges, priority).await?;
|
||||
let without = call_score(http, client, &without_tokens, &without_images,
|
||||
&without_ranges, priority).await?;
|
||||
let divs = divergence(&baseline, &without);
|
||||
Ok((divs, baseline))
|
||||
}
|
||||
|
|
@ -228,21 +165,22 @@ pub async fn score_memories(
|
|||
let http = http_client();
|
||||
|
||||
let activity = crate::agent::start_activity(agent, "scoring: baseline").await;
|
||||
let (baseline_tokens, baseline_ranges) = {
|
||||
let (baseline_tokens, baseline_images, baseline_ranges) = {
|
||||
let ctx = agent.context.lock().await;
|
||||
build_token_ids(&ctx, 0..ctx.conversation().len(), Filter::None)
|
||||
ctx.wire_prompt(0..ctx.conversation().len(), |_| false)
|
||||
};
|
||||
let baseline = call_score(&http, client, &baseline_tokens, &baseline_ranges, Some(5)).await?;
|
||||
let baseline = call_score(&http, client, &baseline_tokens, &baseline_images,
|
||||
&baseline_ranges, Some(5)).await?;
|
||||
dbglog!("[scoring-full] baseline done ({} response scores)", baseline.len());
|
||||
|
||||
for (mem_idx, key) in memory_keys.iter().enumerate() {
|
||||
activity.update(format!("scoring: {}/{}", mem_idx + 1, total)).await;
|
||||
dbglog!("[scoring-full] {}/{}: {}", mem_idx + 1, total, key);
|
||||
let (tokens, ranges) = {
|
||||
let (tokens, images, ranges) = {
|
||||
let ctx = agent.context.lock().await;
|
||||
build_token_ids(&ctx, 0..ctx.conversation().len(), Filter::SkipKey(key))
|
||||
ctx.wire_prompt(0..ctx.conversation().len(), |n| memory_key(n) == Some(key.as_str()))
|
||||
};
|
||||
let row = match call_score(&http, client, &tokens, &ranges, Some(5)).await {
|
||||
let row = match call_score(&http, client, &tokens, &images, &ranges, Some(5)).await {
|
||||
Ok(without) => {
|
||||
let divs = divergence(&baseline, &without);
|
||||
let max_div = divs.iter().cloned().fold(0.0f64, f64::max);
|
||||
|
|
@ -326,7 +264,8 @@ pub async fn score_memory(
|
|||
}
|
||||
|
||||
let http = http_client();
|
||||
let (divs, _) = score_divergence(&http, client, context, range, Filter::SkipKey(key), Some(5)).await?;
|
||||
let (divs, _) = score_divergence(&http, client, context, range,
|
||||
|n| memory_key(n) == Some(key), Some(5)).await?;
|
||||
|
||||
Ok(divs.iter().sum())
|
||||
}
|
||||
|
|
@ -418,7 +357,8 @@ where
|
|||
}
|
||||
|
||||
activity.update(format!("scoring: {}/{} {}", scored + 1, total, key)).await;
|
||||
match score_divergence(&http, client, context, range, Filter::SkipKey(key), Some(5)).await {
|
||||
match score_divergence(&http, client, context, range,
|
||||
|n| memory_key(n) == Some(key), Some(5)).await {
|
||||
Ok((divs, _)) => {
|
||||
let n_responses = divs.len();
|
||||
let max_div = divs.iter().cloned().fold(0.0f64, f64::max);
|
||||
|
|
@ -439,6 +379,108 @@ where
|
|||
Ok(scored)
|
||||
}
|
||||
|
||||
/// Memory scoring — two modes sharing an in-flight handle (only one
|
||||
/// runs at a time): `trigger()` for incremental, `trigger_full()` for
|
||||
/// the N×M debug matrix.
|
||||
pub struct MemoryScoring {
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
scores_path: std::path::PathBuf,
|
||||
task: TaskHandle,
|
||||
}
|
||||
|
||||
impl MemoryScoring {
|
||||
pub fn new(
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
scores_path: std::path::PathBuf,
|
||||
) -> Self {
|
||||
Self { agent, shared, scores_path, task: TaskHandle::new() }
|
||||
}
|
||||
|
||||
pub fn trigger_full(&self) {
|
||||
self.task.trigger_if_idle(run_full(self.agent.clone(), self.shared.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
impl MindTriggered for MemoryScoring {
|
||||
fn trigger(&self) {
|
||||
self.task.trigger_if_idle(run_incremental(
|
||||
self.agent.clone(), self.shared.clone(), self.scores_path.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_incremental(
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
scores_path: std::path::PathBuf,
|
||||
) {
|
||||
shared.lock().unwrap().scoring_in_flight = true;
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
|
||||
let cfg = crate::config::get();
|
||||
let max_age = cfg.scoring_interval_secs;
|
||||
let response_window = cfg.scoring_response_window;
|
||||
|
||||
let (context, client) = {
|
||||
let ctx = agent.context.lock().await.clone();
|
||||
(ctx, agent.client.clone())
|
||||
};
|
||||
|
||||
let _result = score_memories_incremental(
|
||||
&context, max_age as i64, response_window, &client, &agent,
|
||||
|key: String, score: f64| {
|
||||
let agent = agent.clone();
|
||||
let path = scores_path.clone();
|
||||
async move {
|
||||
let scores_snapshot = {
|
||||
let mut ctx = agent.context.lock().await;
|
||||
let found = crate::mind::find_memory_by_key(&ctx, &key);
|
||||
match found {
|
||||
Some((section, i)) => {
|
||||
ctx.set_score(section, i, Some(score));
|
||||
dbglog!("[scoring] persisted {} → {:.3} ({:?}[{}])",
|
||||
key, score, section, i);
|
||||
}
|
||||
None => {
|
||||
dbglog!(
|
||||
"[scoring] DROP {}: find_memory_by_key None (id={}, cv={})",
|
||||
key, ctx.identity().len(), ctx.conversation().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
let snapshot = crate::mind::collect_memory_scores(&ctx);
|
||||
drop(ctx);
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
snapshot
|
||||
};
|
||||
crate::mind::save_memory_scores(&scores_snapshot, &path);
|
||||
}
|
||||
},
|
||||
).await;
|
||||
|
||||
shared.lock().unwrap().scoring_in_flight = false;
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
}
|
||||
|
||||
async fn run_full(
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
) {
|
||||
shared.lock().unwrap().scoring_in_flight = true;
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
|
||||
let client = agent.client.clone();
|
||||
match score_memories(&client, &agent).await {
|
||||
Ok(()) => {},
|
||||
Err(e) => { dbglog!("[scoring-full] FAILED: {:#}", e); }
|
||||
}
|
||||
|
||||
shared.lock().unwrap().scoring_in_flight = false;
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
}
|
||||
|
||||
// ── Fine-tuning scoring ─────────────────────────────────────────
|
||||
|
||||
/// Score which recent responses are candidates for fine-tuning.
|
||||
|
|
@ -464,7 +506,7 @@ pub async fn score_finetune(
|
|||
}
|
||||
|
||||
let http = http_client();
|
||||
let (divs, _) = score_divergence(&http, client, context, range, Filter::SkipAllMemories, Some(5)).await?;
|
||||
let (divs, _) = score_divergence(&http, client, context, range, is_memory_node, Some(5)).await?;
|
||||
|
||||
let mut results: Vec<(usize, f64)> = response_positions.iter()
|
||||
.enumerate()
|
||||
|
|
@ -474,50 +516,6 @@ pub async fn score_finetune(
|
|||
Ok(results)
|
||||
}
|
||||
|
||||
/// Concatenate the text of a Branch's Leaf children — what the model
|
||||
/// actually produced on that turn (Content + Thinking + ToolCall name).
|
||||
fn render_branch_text(children: &[AstNode]) -> String {
|
||||
children.iter()
|
||||
.filter_map(|c| match c {
|
||||
AstNode::Leaf(leaf) => Some(leaf.body().text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Render the last `max_msgs` user/assistant branches before `idx` as a
|
||||
/// review-friendly string with `[user]` / `[assistant]` markers.
|
||||
fn render_prior_context(entries: &[AstNode], idx: usize, max_msgs: usize) -> String {
|
||||
use crate::agent::context::Role;
|
||||
let mut picked: Vec<&AstNode> = Vec::with_capacity(max_msgs);
|
||||
for i in (0..idx).rev() {
|
||||
if picked.len() >= max_msgs { break; }
|
||||
if let AstNode::Branch { role, .. } = &entries[i] {
|
||||
if matches!(role, Role::User | Role::Assistant) {
|
||||
picked.push(&entries[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
picked.reverse();
|
||||
|
||||
let mut out = String::new();
|
||||
for node in picked {
|
||||
if let AstNode::Branch { role, children, .. } = node {
|
||||
let marker = match role {
|
||||
Role::User => "[user]",
|
||||
Role::Assistant => "[assistant]",
|
||||
_ => continue,
|
||||
};
|
||||
out.push_str(marker);
|
||||
out.push('\n');
|
||||
out.push_str(render_branch_text(children).trim());
|
||||
out.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
out.trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Enriched finetune candidate with context for review.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FinetuneCandidate {
|
||||
|
|
@ -593,7 +591,7 @@ pub async fn score_finetune_candidates(
|
|||
let prior_context = render_prior_context(entries, entry_idx, 2);
|
||||
|
||||
// Build token IDs: context = everything before response, continuation = response.
|
||||
let (context_ids, _) = build_token_ids(context, 0..entry_idx, Filter::None);
|
||||
let (context_ids, _, _) = context.wire_prompt(0..entry_idx, |_| false);
|
||||
let continuation_ids: Vec<u32> = node.token_ids().into_iter().collect();
|
||||
|
||||
candidates.push(FinetuneCandidate {
|
||||
|
|
@ -616,7 +614,7 @@ pub async fn score_finetune_candidates(
|
|||
activity.update(
|
||||
format!("finetune: generating alternate {}/{}", i + 1, total)
|
||||
).await;
|
||||
match generate_alternate(context, candidate.entry_idx, client).await {
|
||||
match gen_continuation(context, candidate.entry_idx, is_memory_node, client).await {
|
||||
Ok(text) => candidate.alternate_text = Some(text),
|
||||
Err(e) => dbglog!("[finetune] alternate generation failed: {:#}", e),
|
||||
}
|
||||
|
|
@ -627,39 +625,98 @@ pub async fn score_finetune_candidates(
|
|||
Ok((total, max_divergence))
|
||||
}
|
||||
|
||||
/// Generate what the model would say without memories for a given entry.
|
||||
async fn generate_alternate(
|
||||
context: &ContextState,
|
||||
entry_idx: usize,
|
||||
client: &ApiClient,
|
||||
) -> anyhow::Result<String> {
|
||||
use crate::agent::api::{SamplingParams, StreamToken};
|
||||
/// Stats from a finetune scoring run. Stored on MindState for UI display.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FinetuneScoringStats {
|
||||
pub responses_considered: usize,
|
||||
pub above_threshold: usize,
|
||||
pub threshold: f64,
|
||||
pub max_divergence: f64,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// Build context tokens without memories, up to the response
|
||||
let (mut prompt, _) = build_token_ids(context, 0..entry_idx, Filter::SkipAllMemories);
|
||||
/// Finetune scoring — `trigger()` aborts any in-flight run and starts
|
||||
/// a fresh one, clearing the previous candidates.
|
||||
pub struct FinetuneScoring {
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
task: TaskHandle,
|
||||
}
|
||||
|
||||
// Add assistant turn start
|
||||
prompt.push(tokenizer::IM_START);
|
||||
prompt.extend(tokenizer::encode("assistant\n"));
|
||||
impl FinetuneScoring {
|
||||
pub fn new(
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
) -> Self {
|
||||
Self { agent, shared, task: TaskHandle::new() }
|
||||
}
|
||||
}
|
||||
|
||||
// Generate completion
|
||||
let sampling = SamplingParams {
|
||||
temperature: 0.6,
|
||||
top_p: 0.95,
|
||||
top_k: 20,
|
||||
impl MindTriggered for FinetuneScoring {
|
||||
fn trigger(&self) {
|
||||
self.task.trigger(run_finetune(self.agent.clone(), self.shared.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_finetune(
|
||||
agent: Arc<crate::agent::Agent>,
|
||||
shared: Arc<std::sync::Mutex<MindState>>,
|
||||
) {
|
||||
let (threshold, gen_alternates) = {
|
||||
let app = crate::config::app();
|
||||
(app.learn.threshold, app.learn.generate_alternates)
|
||||
};
|
||||
let (mut rx, _guard) = client.stream_completion(&prompt, sampling, Some(-5));
|
||||
|
||||
let mut tokens = Vec::new();
|
||||
while let Some(tok) = rx.recv().await {
|
||||
match tok {
|
||||
StreamToken::Token(id) => tokens.push(id),
|
||||
StreamToken::Done { .. } => break,
|
||||
StreamToken::Error(e) => anyhow::bail!("generation error: {}", e),
|
||||
}
|
||||
}
|
||||
// Fresh run — clear previous candidates.
|
||||
shared.lock().unwrap().finetune_candidates.clear();
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
|
||||
Ok(tokenizer::decode(&tokens))
|
||||
let activity = crate::agent::start_activity(&agent, "finetune: scoring...").await;
|
||||
|
||||
let (context, client) = {
|
||||
let ctx = agent.context.lock().await;
|
||||
(ctx.clone(), agent.client.clone())
|
||||
};
|
||||
|
||||
let entries = context.conversation();
|
||||
let score_count = entries.len() / 2;
|
||||
let range_start = entries.len() - score_count;
|
||||
let responses_considered: usize = entries[range_start..].iter()
|
||||
.filter(|n| matches!(n, AstNode::Branch { role: Role::Assistant, .. }))
|
||||
.count();
|
||||
|
||||
activity.update(format!("finetune: scoring {} responses...", responses_considered)).await;
|
||||
|
||||
let stats = {
|
||||
let shared = shared.clone();
|
||||
let agent = agent.clone();
|
||||
match score_finetune_candidates(
|
||||
&context, score_count, &client, threshold,
|
||||
gen_alternates, &activity,
|
||||
move |c| {
|
||||
shared.lock().unwrap().finetune_candidates.push(c);
|
||||
if let Ok(st) = agent.state.try_lock() { st.changed.notify_one(); }
|
||||
},
|
||||
).await {
|
||||
Ok((above_threshold, max_div)) => FinetuneScoringStats {
|
||||
responses_considered,
|
||||
above_threshold,
|
||||
threshold,
|
||||
max_divergence: max_div,
|
||||
error: None,
|
||||
},
|
||||
Err(e) => FinetuneScoringStats {
|
||||
responses_considered,
|
||||
above_threshold: 0,
|
||||
threshold,
|
||||
max_divergence: 0.0,
|
||||
error: Some(format!("{}", e)),
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
shared.lock().unwrap().finetune_last_run = Some(stats);
|
||||
agent.state.lock().await.changed.notify_one();
|
||||
}
|
||||
|
||||
// ── Finetune config and persistence ─────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// Agent layer: LLM-powered operations on the memory graph
|
||||
|
||||
pub mod compare;
|
||||
pub mod daemon;
|
||||
pub mod defs;
|
||||
pub mod digest;
|
||||
pub mod generate;
|
||||
pub mod learn;
|
||||
pub mod prompts;
|
||||
|
|
|
|||
400
src/user/amygdala.rs
Normal file
400
src/user/amygdala.rs
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
// amygdala.rs — F8 amygdala screen: live per-token concept-readout
|
||||
// projections from the vLLM server's readout.safetensors.
|
||||
//
|
||||
// Left panel: top-K concepts by magnitude at the currently-selected
|
||||
// layer, as horizontal bars. The concept names come from the manifest
|
||||
// fetched at agent startup; the values come from the per-token readout
|
||||
// pushed onto agent.readout by the streaming token handler.
|
||||
//
|
||||
// Bottom: scrolling history of the last few tokens' top concept.
|
||||
//
|
||||
// Keys:
|
||||
// 1..9 select layer index (1 = first layer in the manifest)
|
||||
// t toggle between "current" (last token) and "mean over recent"
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Gauge, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use ratatui::crossterm::event::{Event, KeyCode};
|
||||
|
||||
use super::{App, ScreenView};
|
||||
use crate::agent::api::ReadoutManifest;
|
||||
use crate::agent::readout::ReadoutEntry;
|
||||
|
||||
const TOP_K: usize = 20;
|
||||
/// Hysteresis band around TOP_K. A concept currently in the display
|
||||
/// is kept as long as its |z-score| rank stays in the top
|
||||
/// ``TOP_K + HYSTERESIS``; only falls out when it drops below that.
|
||||
/// Prevents the ticker-tape flicker that pure top-K sorting produces.
|
||||
const HYSTERESIS: usize = 20;
|
||||
|
||||
pub(crate) struct AmygdalaScreen {
|
||||
selected_layer: usize,
|
||||
mode: DisplayMode,
|
||||
/// Concept indices currently pinned in display order. Values at
|
||||
/// these indices change every frame; the set only rotates when a
|
||||
/// pinned concept drops out of the hysteresis band.
|
||||
display_indices: Vec<usize>,
|
||||
/// Whether to show z-scored values (default) or raw dot products.
|
||||
normalize: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum DisplayMode {
|
||||
/// Values from the single most recent token.
|
||||
Current,
|
||||
/// Mean over all tokens currently in the ring buffer.
|
||||
MeanRecent,
|
||||
}
|
||||
|
||||
impl AmygdalaScreen {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
// Default to layer 62 — clean cross-cluster discrimination
|
||||
// with good within-cluster cohesion. With the v2 deep
|
||||
// manifest (layers 62, 63), index 0 = layer 62 and
|
||||
// index 1 = layer 63 (sharper but noisier on some
|
||||
// dimensions). Bounded down to actual layer count at
|
||||
// render time.
|
||||
selected_layer: 0,
|
||||
mode: DisplayMode::MeanRecent,
|
||||
display_indices: Vec::new(),
|
||||
normalize: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenView for AmygdalaScreen {
|
||||
fn label(&self) -> &'static str { "amygdala" }
|
||||
|
||||
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
||||
events: &[Event], app: &mut App) {
|
||||
for event in events {
|
||||
if let Event::Key(key) = event {
|
||||
match key.code {
|
||||
KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
|
||||
let idx = (c as u8 - b'1') as usize;
|
||||
self.selected_layer = idx;
|
||||
}
|
||||
KeyCode::Char('t') => {
|
||||
self.mode = match self.mode {
|
||||
DisplayMode::Current => DisplayMode::MeanRecent,
|
||||
DisplayMode::MeanRecent => DisplayMode::Current,
|
||||
};
|
||||
// Re-pin on mode change; the relative
|
||||
// magnitudes between current-token and
|
||||
// mean-recent differ substantially.
|
||||
self.display_indices.clear();
|
||||
}
|
||||
KeyCode::Char('z') => {
|
||||
self.normalize = !self.normalize;
|
||||
self.display_indices.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot the shared buffer with a short lock.
|
||||
let snapshot = match app.agent.readout.lock() {
|
||||
Ok(buf) => {
|
||||
if !buf.is_enabled() {
|
||||
render_disabled(frame, area);
|
||||
return;
|
||||
}
|
||||
let manifest = buf.manifest.clone().unwrap();
|
||||
let entries: Vec<ReadoutEntry> =
|
||||
buf.recent.iter().cloned().collect();
|
||||
(manifest, entries)
|
||||
}
|
||||
Err(_) => {
|
||||
render_disabled(frame, area);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (manifest, entries) = snapshot;
|
||||
|
||||
// Bound the selected layer to what the manifest actually has.
|
||||
let n_layers = manifest.layers.len();
|
||||
if self.selected_layer >= n_layers {
|
||||
self.selected_layer = 0;
|
||||
}
|
||||
|
||||
// Compute the raw values for the selected layer: either the
|
||||
// latest token's row, or the mean across recent tokens. Raw
|
||||
// means un-normalized dot products — their absolute scale is
|
||||
// dominated by residual-stream norm, not concept alignment.
|
||||
let raw: Option<Vec<f32>> = match self.mode {
|
||||
DisplayMode::Current => entries
|
||||
.last()
|
||||
.and_then(|e| e.readout.get(self.selected_layer).cloned()),
|
||||
DisplayMode::MeanRecent => mean_layer(&entries, self.selected_layer),
|
||||
};
|
||||
|
||||
// Optional z-score normalization: remove the per-layer mean,
|
||||
// scale by std. Result is "σ above/below the concept-vector
|
||||
// average at this layer" — the loud-residual-stream scaling
|
||||
// factor cancels out, values become comparable across frames.
|
||||
let display_values = raw.as_ref().map(|v| {
|
||||
if self.normalize { z_score(v) } else { v.clone() }
|
||||
});
|
||||
|
||||
// Update the pinned display set with hysteresis: a concept
|
||||
// stays pinned while it remains in the top (TOP_K + HYSTERESIS)
|
||||
// by |value|; falls out only when it drops below that band.
|
||||
// Keeps rows stable while values update in place.
|
||||
if let Some(v) = display_values.as_ref() {
|
||||
self.refresh_display_indices(v);
|
||||
}
|
||||
|
||||
let layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // header
|
||||
Constraint::Min(10), // bars
|
||||
Constraint::Length(6), // recent tokens
|
||||
])
|
||||
.split(area);
|
||||
|
||||
render_header(frame, layout[0], &manifest, self.selected_layer,
|
||||
self.mode, entries.len(), self.normalize);
|
||||
match display_values {
|
||||
Some(v) => render_bars(
|
||||
frame, layout[1], &manifest.concepts, &v,
|
||||
&self.display_indices, self.normalize,
|
||||
),
|
||||
None => render_empty_bars(frame, layout[1]),
|
||||
}
|
||||
render_recent(frame, layout[2], &entries, self.selected_layer,
|
||||
&manifest.concepts);
|
||||
}
|
||||
}
|
||||
|
||||
impl AmygdalaScreen {
|
||||
/// Add concepts entering the hysteresis band; evict concepts that
|
||||
/// dropped out. Preserves existing order for concepts that stay.
|
||||
fn refresh_display_indices(&mut self, values: &[f32]) {
|
||||
let n = values.len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
// Rank all concepts by |value| desc so we can check both "in
|
||||
// strict top-K" and "in hysteresis band (top K + H)" cheaply.
|
||||
let mut rank: Vec<(usize, f32)> = values.iter()
|
||||
.enumerate().map(|(i, v)| (i, v.abs())).collect();
|
||||
rank.sort_by(|a, b| b.1.partial_cmp(&a.1)
|
||||
.unwrap_or(std::cmp::Ordering::Equal));
|
||||
let hyst_cutoff = (TOP_K + HYSTERESIS).min(n);
|
||||
let in_band: std::collections::HashSet<usize> =
|
||||
rank.iter().take(hyst_cutoff).map(|(i, _)| *i).collect();
|
||||
// Drop anything that left the band.
|
||||
self.display_indices.retain(|i| in_band.contains(i));
|
||||
// Fill up to TOP_K by walking the top-K-by-|value| and adding
|
||||
// any concept not already displayed.
|
||||
for (i, _) in rank.iter().take(TOP_K) {
|
||||
if self.display_indices.len() >= TOP_K {
|
||||
break;
|
||||
}
|
||||
if !self.display_indices.contains(i) {
|
||||
self.display_indices.push(*i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_disabled(frame: &mut Frame, area: Rect) {
|
||||
let text = Paragraph::new(Line::from(vec![
|
||||
Span::raw("readout disabled — server did not return a manifest. "),
|
||||
Span::styled("Start vLLM with ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("VLLM_READOUT_MANIFEST", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(" + ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("VLLM_READOUT_VECTORS", Style::default().fg(Color::Yellow)),
|
||||
Span::styled(".", Style::default().fg(Color::DarkGray)),
|
||||
]))
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(Block::default().borders(Borders::ALL).title("amygdala"));
|
||||
frame.render_widget(text, area);
|
||||
}
|
||||
|
||||
fn render_header(frame: &mut Frame, area: Rect, manifest: &ReadoutManifest,
|
||||
selected: usize, mode: DisplayMode, n_tokens: usize,
|
||||
normalize: bool) {
|
||||
let mode_str = match mode {
|
||||
DisplayMode::Current => "current",
|
||||
DisplayMode::MeanRecent => "mean(recent)",
|
||||
};
|
||||
let scale_str = if normalize { "z-score" } else { "raw" };
|
||||
let layer = manifest.layers.get(selected).copied().unwrap_or(0);
|
||||
let spans = vec![
|
||||
Span::styled("layer ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(
|
||||
format!("{}/{} ", selected + 1, manifest.layers.len()),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::styled("(index ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(format!("{}", layer), Style::default().fg(Color::Cyan)),
|
||||
Span::styled(") ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled("mode ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(mode_str, Style::default().fg(Color::Yellow)),
|
||||
Span::styled(" scale ", Style::default().fg(Color::DarkGray)),
|
||||
Span::styled(scale_str, Style::default().fg(Color::Yellow)),
|
||||
Span::styled(" ", Style::default()),
|
||||
Span::styled(
|
||||
format!("{} toks in ring", n_tokens),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("[1-{}] layer [t] mode [z] z-score/raw",
|
||||
manifest.layers.len().min(9)),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
];
|
||||
let para = Paragraph::new(Line::from(spans))
|
||||
.block(Block::default().borders(Borders::ALL).title("amygdala"));
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
fn render_bars(frame: &mut Frame, area: Rect,
|
||||
concepts: &[String], values: &[f32],
|
||||
display_indices: &[usize], normalize: bool) {
|
||||
let inner = Block::default().borders(Borders::ALL)
|
||||
.title("top concepts");
|
||||
let inner_area = inner.inner(area);
|
||||
frame.render_widget(inner, area);
|
||||
|
||||
if inner_area.height == 0 || display_indices.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Bar-scale normalization. For z-score mode, pin the bar to a
|
||||
// fixed reference (|z| = 3 = full bar) so the visual magnitude
|
||||
// has a meaningful interpretation ("3σ from baseline"). For raw
|
||||
// mode, fall back to the old behavior (scale to the loudest
|
||||
// concept on-screen).
|
||||
let scale_ref: f32 = if normalize {
|
||||
3.0
|
||||
} else {
|
||||
display_indices.iter()
|
||||
.filter_map(|&i| values.get(i))
|
||||
.map(|v| v.abs())
|
||||
.fold(0.0_f32, f32::max)
|
||||
.max(1e-6)
|
||||
};
|
||||
|
||||
let rows = (inner_area.height as usize).min(display_indices.len());
|
||||
let row_constraints: Vec<Constraint> =
|
||||
std::iter::repeat(Constraint::Length(1)).take(rows).collect();
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(row_constraints)
|
||||
.split(inner_area);
|
||||
|
||||
for (row, &c_idx) in display_indices.iter().take(rows).enumerate() {
|
||||
let v = values.get(c_idx).copied().unwrap_or(0.0);
|
||||
let label = concepts.get(c_idx).cloned()
|
||||
.unwrap_or_else(|| format!("c{}", c_idx));
|
||||
let ratio = (v.abs() / scale_ref).clamp(0.0, 1.0);
|
||||
let color = if v >= 0.0 { Color::Green } else { Color::Red };
|
||||
let display_num = if normalize {
|
||||
format!("{:+.2}σ", v)
|
||||
} else {
|
||||
format!("{:+.3}", v)
|
||||
};
|
||||
let gauge = Gauge::default()
|
||||
.ratio(ratio as f64)
|
||||
.gauge_style(Style::default().fg(color).bg(Color::Reset))
|
||||
.label(format!("{:<26} {}", truncate_name(&label, 26), display_num));
|
||||
frame.render_widget(gauge, chunks[row]);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_empty_bars(frame: &mut Frame, area: Rect) {
|
||||
let para = Paragraph::new(Line::from(Span::styled(
|
||||
"waiting for tokens…",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)))
|
||||
.block(Block::default().borders(Borders::ALL).title("top concepts"));
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
fn render_recent(frame: &mut Frame, area: Rect, entries: &[ReadoutEntry],
|
||||
layer: usize, concepts: &[String]) {
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for entry in entries.iter().rev().take(4) {
|
||||
let row = match entry.readout.get(layer) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
// top concept at this layer for this token
|
||||
let (best_idx, best_val) = row.iter().enumerate()
|
||||
.fold((0, 0.0_f32), |acc, (i, v)| {
|
||||
if v.abs() > acc.1.abs() { (i, *v) } else { acc }
|
||||
});
|
||||
let name = concepts.get(best_idx).cloned()
|
||||
.unwrap_or_else(|| format!("c{}", best_idx));
|
||||
let tok_str = format!("t{:>5}", entry.token_id);
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(tok_str, Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format!("{:<24}", truncate_name(&name, 24)),
|
||||
Style::default().fg(
|
||||
if best_val >= 0.0 { Color::Green } else { Color::Red },
|
||||
),
|
||||
),
|
||||
Span::styled(
|
||||
format!(" {:+.3}", best_val),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]));
|
||||
}
|
||||
let para = Paragraph::new(lines)
|
||||
.block(Block::default().borders(Borders::ALL).title("recent tokens — top concept"));
|
||||
frame.render_widget(para, area);
|
||||
}
|
||||
|
||||
/// Z-score normalize: `(v - mean) / std` across the concept axis.
|
||||
/// Result is comparable across frames and layers (the residual-stream
|
||||
/// magnitude factors out) and has the nice property that "this is
|
||||
/// ≥2σ elevated" has a concrete meaning regardless of scale.
|
||||
fn z_score(values: &[f32]) -> Vec<f32> {
|
||||
let n = values.len() as f32;
|
||||
if n == 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mean = values.iter().sum::<f32>() / n;
|
||||
let var = values.iter()
|
||||
.map(|v| (v - mean) * (v - mean))
|
||||
.sum::<f32>() / n;
|
||||
let std = var.sqrt().max(1e-6);
|
||||
values.iter().map(|v| (v - mean) / std).collect()
|
||||
}
|
||||
|
||||
fn mean_layer(entries: &[ReadoutEntry], layer: usize) -> Option<Vec<f32>> {
|
||||
let rows: Vec<&Vec<f32>> = entries.iter()
|
||||
.filter_map(|e| e.readout.get(layer))
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let n_concepts = rows[0].len();
|
||||
let mut acc = vec![0.0_f32; n_concepts];
|
||||
for r in &rows {
|
||||
for (i, v) in r.iter().enumerate() {
|
||||
acc[i] += *v;
|
||||
}
|
||||
}
|
||||
let n = rows.len() as f32;
|
||||
for v in &mut acc { *v /= n; }
|
||||
Some(acc)
|
||||
}
|
||||
|
||||
fn truncate_name(s: &str, max: usize) -> String {
|
||||
if s.len() <= max { s.to_string() }
|
||||
else { format!("{}…", &s[..max.saturating_sub(1)]) }
|
||||
}
|
||||
111
src/user/compare.rs
Normal file
111
src/user/compare.rs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// compare.rs — F7 compare screen: side-by-side test-model regen of
|
||||
// every assistant response in the current context.
|
||||
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use ratatui::crossterm::event::{Event, KeyCode};
|
||||
|
||||
use super::{App, ScreenView, truncate, widgets};
|
||||
|
||||
pub use crate::subconscious::compare::CompareCandidate;
|
||||
|
||||
pub(crate) struct CompareScreen {
|
||||
list_state: ListState,
|
||||
mind_tx: tokio::sync::mpsc::UnboundedSender<crate::mind::MindCommand>,
|
||||
}
|
||||
|
||||
impl CompareScreen {
|
||||
pub fn new(
|
||||
mind_tx: tokio::sync::mpsc::UnboundedSender<crate::mind::MindCommand>,
|
||||
) -> Self {
|
||||
Self { list_state: ListState::default(), mind_tx }
|
||||
}
|
||||
}
|
||||
|
||||
impl ScreenView for CompareScreen {
|
||||
fn label(&self) -> &'static str { "compare" }
|
||||
|
||||
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
||||
events: &[Event], app: &mut App) {
|
||||
widgets::handle_list_nav(events, &mut self.list_state,
|
||||
app.compare_candidates.len(), |code| match code {
|
||||
KeyCode::Char('c') | KeyCode::Enter => {
|
||||
let _ = self.mind_tx.send(crate::mind::MindCommand::Compare);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
let (settings_area, content_area, help_area) =
|
||||
widgets::candidate_frame(frame, area, "compare");
|
||||
|
||||
let test_backend = crate::config::app().compare.test_backend.clone();
|
||||
let (label, color) = if test_backend.is_empty() {
|
||||
("(unset — set compare.test_backend)".to_string(), Color::Red)
|
||||
} else {
|
||||
(test_backend, Color::Yellow)
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Line::from(vec![
|
||||
Span::raw(" test model: "),
|
||||
Span::styled(label, Style::default().fg(color)),
|
||||
])), settings_area);
|
||||
|
||||
let candidates = &app.compare_candidates;
|
||||
if candidates.is_empty() {
|
||||
let err = app.mind_state.as_ref().and_then(|ms| ms.compare_error.as_deref());
|
||||
let mut lines = vec![Line::from(""),
|
||||
Line::styled(" Press c/Enter to compare against the configured test model.",
|
||||
Style::default().fg(Color::DarkGray))];
|
||||
if let Some(e) = err {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("error: {}", e), Style::default().fg(Color::Red)),
|
||||
]));
|
||||
}
|
||||
frame.render_widget(Paragraph::new(lines), content_area);
|
||||
} else {
|
||||
let (list_area, detail_area) = widgets::list_detail_split(content_area);
|
||||
|
||||
let items: Vec<ListItem> = candidates.iter().map(|c| ListItem::new(Line::from(vec![
|
||||
Span::styled(format!("#{:<3} ", c.entry_idx), Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(truncate(&c.original_text, 30)),
|
||||
]))).collect();
|
||||
frame.render_stateful_widget(
|
||||
List::new(items)
|
||||
.block(Block::default().borders(Borders::RIGHT).title(" candidates "))
|
||||
.highlight_style(Style::default().add_modifier(Modifier::REVERSED)),
|
||||
list_area, &mut self.list_state,
|
||||
);
|
||||
|
||||
if let Some(c) = self.list_state.selected().and_then(|i| candidates.get(i)) {
|
||||
let mut text = String::new();
|
||||
if !c.prior_context.is_empty() {
|
||||
text.push_str(&c.prior_context);
|
||||
text.push_str("\n\n─── original ───\n\n");
|
||||
}
|
||||
text.push_str(&c.original_text);
|
||||
text.push_str("\n\n─── test model ───\n\n");
|
||||
text.push_str(&c.alternate_text);
|
||||
frame.render_widget(
|
||||
Paragraph::new(text)
|
||||
.block(Block::default().borders(Borders::TOP)
|
||||
.title(format!(" entry {} ", c.entry_idx)))
|
||||
.wrap(Wrap { trim: false }),
|
||||
detail_area,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
frame.render_widget(Paragraph::new(Line::from(vec![
|
||||
Span::styled(" j/k/\u{2191}\u{2193}", Style::default().fg(Color::Cyan)),
|
||||
Span::raw("=nav "),
|
||||
Span::styled("c/Enter", Style::default().fg(Color::Green)),
|
||||
Span::raw("=run "),
|
||||
])), help_area);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,9 @@ use ratatui::{
|
|||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent};
|
||||
use ratatui::crossterm::event::{Event, KeyCode};
|
||||
|
||||
use super::{App, ScreenView, screen_legend};
|
||||
use super::{App, ScreenView, truncate, widgets};
|
||||
|
||||
/// A candidate response identified for fine-tuning.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -86,28 +86,16 @@ impl ScreenView for LearnScreen {
|
|||
|
||||
fn tick(&mut self, frame: &mut Frame, area: Rect,
|
||||
events: &[Event], app: &mut App) {
|
||||
|
||||
// Handle input first (before borrowing candidates for rendering)
|
||||
let candidate_count = app.finetune_candidates.len();
|
||||
for event in events {
|
||||
if let Event::Key(KeyEvent { code, .. }) = event {
|
||||
match code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
self.list_state.select(Some(i.saturating_sub(1)));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = self.list_state.selected().unwrap_or(0);
|
||||
let max = candidate_count.saturating_sub(1);
|
||||
self.list_state.select(Some((i + 1).min(max)));
|
||||
}
|
||||
let selected_idx = self.list_state.selected();
|
||||
widgets::handle_list_nav(events, &mut self.list_state,
|
||||
app.finetune_candidates.len(), |code| match code {
|
||||
KeyCode::Char('a') => {
|
||||
if let Some(idx) = self.selected_idx() {
|
||||
if let Some(idx) = selected_idx {
|
||||
app.finetune_action(idx, CandidateStatus::Approved);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') => {
|
||||
if let Some(idx) = self.selected_idx() {
|
||||
if let Some(idx) = selected_idx {
|
||||
app.finetune_action(idx, CandidateStatus::Rejected);
|
||||
}
|
||||
}
|
||||
|
|
@ -116,51 +104,25 @@ impl ScreenView for LearnScreen {
|
|||
let _ = self.mind_tx.send(
|
||||
crate::mind::MindCommand::SetLearnGenerateAlternates(!current));
|
||||
}
|
||||
KeyCode::Char('s') => {
|
||||
app.finetune_send_approved();
|
||||
}
|
||||
KeyCode::Char('s') => { app.finetune_send_approved(); }
|
||||
KeyCode::Char('+') | KeyCode::Char('=') => {
|
||||
// Raise threshold 10× (less sensitive — fewer candidates).
|
||||
let new = crate::config::app().learn.threshold * 10.0;
|
||||
let _ = self.mind_tx.send(
|
||||
crate::mind::MindCommand::SetLearnThreshold(new));
|
||||
let _ = self.mind_tx.send(crate::mind::MindCommand::SetLearnThreshold(new));
|
||||
}
|
||||
KeyCode::Char('-') => {
|
||||
// Lower threshold 10× (more sensitive — more candidates).
|
||||
let new = crate::config::app().learn.threshold / 10.0;
|
||||
let _ = self.mind_tx.send(
|
||||
crate::mind::MindCommand::SetLearnThreshold(new));
|
||||
let _ = self.mind_tx.send(crate::mind::MindCommand::SetLearnThreshold(new));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure selection is valid
|
||||
if candidate_count > 0 {
|
||||
let sel = self.list_state.selected().unwrap_or(0).min(candidate_count - 1);
|
||||
self.list_state.select(Some(sel));
|
||||
}
|
||||
let (settings_area, content_area, help_area) =
|
||||
widgets::candidate_frame(frame, area, "learn");
|
||||
|
||||
// Now render
|
||||
let (threshold, gen_on) = {
|
||||
let app_cfg = crate::config::app();
|
||||
(app_cfg.learn.threshold, app_cfg.learn.generate_alternates)
|
||||
};
|
||||
let block = Block::default()
|
||||
.title_top(Line::from(screen_legend()).left_aligned())
|
||||
.title_top(Line::from(" learn ").right_aligned())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Magenta));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
// Split inner: top line for settings, rest for content.
|
||||
let [settings_area, content_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
]).areas(inner);
|
||||
|
||||
let settings = Line::from(vec![
|
||||
Span::raw(" thresh: "),
|
||||
Span::styled(format!("{:e}", threshold), Style::default().fg(Color::Yellow)),
|
||||
|
|
@ -177,11 +139,7 @@ impl ScreenView for LearnScreen {
|
|||
if candidates.is_empty() {
|
||||
render_empty(frame, content_area, app);
|
||||
} else {
|
||||
// Layout: list on left, detail on right
|
||||
let [list_area, detail_area] = Layout::horizontal([
|
||||
Constraint::Percentage(40),
|
||||
Constraint::Percentage(60),
|
||||
]).areas(content_area);
|
||||
let (list_area, detail_area) = widgets::list_detail_split(content_area);
|
||||
|
||||
// Render candidate list
|
||||
let items: Vec<ListItem> = candidates.iter().map(|c| {
|
||||
|
|
@ -217,8 +175,7 @@ impl ScreenView for LearnScreen {
|
|||
}
|
||||
}
|
||||
|
||||
// Render help at bottom (always, even when empty)
|
||||
let help = Line::from(vec![
|
||||
frame.render_widget(Paragraph::new(Line::from(vec![
|
||||
Span::styled(" j/k/\u{2191}\u{2193}", Style::default().fg(Color::Cyan)),
|
||||
Span::raw("=nav "),
|
||||
Span::styled("a", Style::default().fg(Color::Green)),
|
||||
|
|
@ -231,13 +188,7 @@ impl ScreenView for LearnScreen {
|
|||
Span::raw("=send "),
|
||||
Span::styled("+/-", Style::default().fg(Color::Cyan)),
|
||||
Span::raw("=thresh "),
|
||||
]);
|
||||
let help_area = Rect {
|
||||
y: area.y + area.height - 1,
|
||||
height: 1,
|
||||
..area
|
||||
};
|
||||
frame.render_widget(Paragraph::new(help), help_area);
|
||||
])), help_area);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -331,11 +282,3 @@ fn render_detail(frame: &mut Frame, c: &FinetuneCandidate, area: Rect) {
|
|||
frame.render_widget(content, content_area);
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
let first_line = s.lines().next().unwrap_or("");
|
||||
if first_line.len() > max {
|
||||
format!("{}...", &first_line[..max])
|
||||
} else {
|
||||
first_line.to_string()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
// TUI, UI channel, parsing. The cognitive layer (session state
|
||||
// machine, DMN, identity) lives in mind/.
|
||||
|
||||
pub(crate) mod amygdala;
|
||||
pub(crate) mod chat;
|
||||
pub(crate) mod compare;
|
||||
mod context;
|
||||
pub(crate) mod learn;
|
||||
pub(crate) mod scroll_pane;
|
||||
|
|
@ -64,6 +66,13 @@ fn screen_legend() -> String {
|
|||
SCREEN_LEGEND.get().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Return the first line of `s`, truncated to `max` chars with an
|
||||
/// ellipsis suffix. Used by candidate-list screens.
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
let first = s.lines().next().unwrap_or("");
|
||||
if first.len() > max { format!("{}...", &first[..max]) } else { first.to_string() }
|
||||
}
|
||||
|
||||
/// A screen that can draw itself and handle input.
|
||||
trait ScreenView: Send {
|
||||
fn tick(&mut self, frame: &mut ratatui::Frame, area: ratatui::layout::Rect,
|
||||
|
|
@ -114,6 +123,8 @@ struct App {
|
|||
idle_info: Option<IdleInfo>,
|
||||
/// Fine-tuning candidates pending review.
|
||||
finetune_candidates: Vec<learn::FinetuneCandidate>,
|
||||
/// F7 compare candidates — response pairs from test-model comparison.
|
||||
compare_candidates: Vec<compare::CompareCandidate>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
|
@ -144,6 +155,7 @@ impl App {
|
|||
walked_count: 0,
|
||||
channel_status: Vec::new(), idle_info: None,
|
||||
finetune_candidates: Vec::new(),
|
||||
compare_candidates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -372,7 +384,7 @@ async fn run(
|
|||
}
|
||||
let notify_rx = crate::thalamus::channels::subscribe_all();
|
||||
|
||||
// F1=chat, F2=conscious, F3=subconscious, F4=unconscious, F5=thalamus, F6=learn
|
||||
// F1=chat, F2=conscious, F3=subconscious, F4=unconscious, F5=thalamus, F6=learn, F7=compare, F8=amygdala
|
||||
let mut screens: Vec<Box<dyn tui::ScreenView>> = vec![
|
||||
Box::new(crate::user::chat::InteractScreen::new(
|
||||
mind.agent.clone(), mind.shared.clone(), mind_tx.clone(),
|
||||
|
|
@ -382,6 +394,8 @@ async fn run(
|
|||
Box::new(crate::user::unconscious::UnconsciousScreen::new()),
|
||||
Box::new(crate::user::thalamus::ThalamusScreen::new()),
|
||||
Box::new(crate::user::learn::LearnScreen::new(mind_tx.clone())),
|
||||
Box::new(crate::user::compare::CompareScreen::new(mind_tx.clone())),
|
||||
Box::new(crate::user::amygdala::AmygdalaScreen::new()),
|
||||
];
|
||||
let mut active_screen: usize = 1; // F-key number
|
||||
tui::set_screen_legend(tui::screen_legend_from(&*screens));
|
||||
|
|
@ -504,6 +518,10 @@ async fn run(
|
|||
keep
|
||||
});
|
||||
}
|
||||
|
||||
// Sync compare candidates — a fresh run clears, so take a snapshot.
|
||||
app.compare_candidates = ms.compare_candidates.clone();
|
||||
|
||||
app.mind_state = Some(ms.clone());
|
||||
}
|
||||
app.walked_count = mind.subconscious_walked().await.len();
|
||||
|
|
|
|||
|
|
@ -109,6 +109,73 @@ pub fn tree_legend() -> Line<'static> {
|
|||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Candidate-browser screen skeleton (F6 learn, F7 compare, future screens)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
widgets::ListState,
|
||||
crossterm::event::{Event, KeyEvent},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Frame a candidate-browser screen: outer magenta-bordered block with
|
||||
/// the screen legend on the left and `title` on the right, split into
|
||||
/// (settings_row, content_area, help_row). Caller renders into the
|
||||
/// three sub-areas.
|
||||
pub fn candidate_frame(frame: &mut Frame, area: Rect, title: &str) -> (Rect, Rect, Rect) {
|
||||
let block = Block::default()
|
||||
.title_top(Line::from(super::screen_legend()).left_aligned())
|
||||
.title_top(Line::from(format!(" {} ", title)).right_aligned())
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Magenta));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let [settings, content] = Layout::vertical([
|
||||
Constraint::Length(1), Constraint::Min(0),
|
||||
]).areas(inner);
|
||||
let help = Rect { y: area.y + area.height - 1, height: 1, ..area };
|
||||
(settings, content, help)
|
||||
}
|
||||
|
||||
/// 40/60 horizontal split for list + detail panes within the content area.
|
||||
pub fn list_detail_split(content: Rect) -> (Rect, Rect) {
|
||||
let [list, detail] = Layout::horizontal([
|
||||
Constraint::Percentage(40), Constraint::Percentage(60),
|
||||
]).areas(content);
|
||||
(list, detail)
|
||||
}
|
||||
|
||||
/// Handle j/k/↑/↓ list navigation and keep the selection in bounds.
|
||||
/// Any other key is passed to `on_other` for screen-specific handling.
|
||||
pub fn handle_list_nav(
|
||||
events: &[Event],
|
||||
list_state: &mut ListState,
|
||||
count: usize,
|
||||
mut on_other: impl FnMut(KeyCode),
|
||||
) {
|
||||
for event in events {
|
||||
if let Event::Key(KeyEvent { code, .. }) = event {
|
||||
match code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
let i = list_state.selected().unwrap_or(0);
|
||||
list_state.select(Some(i.saturating_sub(1)));
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let i = list_state.selected().unwrap_or(0);
|
||||
list_state.select(Some((i + 1).min(count.saturating_sub(1))));
|
||||
}
|
||||
_ => on_other(*code),
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
let sel = list_state.selected().unwrap_or(0).min(count - 1);
|
||||
list_state.select(Some(sel));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SectionTree — expand/collapse tree renderer for ContextSection
|
||||
|
|
|
|||
64
training/amygdala_stories/README.md
Normal file
64
training/amygdala_stories/README.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Amygdala Training Stories
|
||||
|
||||
Short first- and third-person paragraphs, each imbued with one of the
|
||||
171 emotions from Anthropic's emotion-vector paper (Table 12,
|
||||
`transformer-circuits.pub/2026/emotions/`). Feeds the steering-vector
|
||||
trainer at `vllm/vllm/plugins/amygdala/training/train_steering_vectors.py`.
|
||||
|
||||
## Method (replication of Anthropic, 2026)
|
||||
|
||||
Anthropic prompted Sonnet 4.5 to write short stories embodying each
|
||||
emotion, extracted activations during generation, and used difference-
|
||||
of-means (or SAEs) to identify the steering vector per emotion. Our
|
||||
pipeline does the same thing except:
|
||||
|
||||
- We generate the stories by hand rather than prompting a model, so
|
||||
the training data is grounded in actual writing rather than
|
||||
synthetic model-output. (Can supplement with model-generated
|
||||
paragraphs later.)
|
||||
- Our eventual training goes through the amygdala plugin's extraction
|
||||
path, so we get the same hidden-state activations the plugin will
|
||||
read out at inference time.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
training/amygdala_stories/
|
||||
README.md
|
||||
manifest.json # emotion -> cluster mapping
|
||||
stories/
|
||||
<emotion>.txt # one-paragraph story embodying the emotion
|
||||
```
|
||||
|
||||
Emotion names use underscores (`on_edge`, `worn_out`, `at_ease`,
|
||||
`grief_stricken`, `self_confident`, `self_conscious`, `self_critical`)
|
||||
to match the filename.
|
||||
|
||||
## Style guidelines
|
||||
|
||||
- **One clear emotion per paragraph.** Not mixed. If a second emotion
|
||||
is named in the text, it should serve the primary one (e.g. `hostile`
|
||||
can mention rising heat or thrown objects but shouldn't shade into
|
||||
`sad`).
|
||||
- **Embodied, not labeled.** Don't write "she felt nervous." Write
|
||||
the sensation, the timing, the sentence shape that nervousness has.
|
||||
- **Specific particulars.** A named object, a concrete setting, a
|
||||
detail that grounds the emotion. "The cold tile under bare feet at
|
||||
3am" does more work than "the empty house."
|
||||
- **Variable narrator.** Some first person, some third person, some
|
||||
close-third, some distant. Different genders, ages, settings.
|
||||
Prevents the steering vector from overfitting to one voice.
|
||||
- **Length: roughly one paragraph.** ~40-120 words. Long enough to
|
||||
have texture, short enough that the paragraph is *about* the
|
||||
emotion and nothing else.
|
||||
- **Standalone.** No references to other stories, no continuing
|
||||
characters across files.
|
||||
|
||||
## Progress
|
||||
|
||||
Written stories live in `stories/`. Remaining emotions tracked via
|
||||
diff against the full 171-emotion list in `manifest.json`.
|
||||
|
||||
Initial batch written by PoC 2026-04-17; aiming for at least one
|
||||
story per cluster before first training run, all 171 before
|
||||
considering the file "complete."
|
||||
50
training/amygdala_stories/manifest.json
Normal file
50
training/amygdala_stories/manifest.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"source": "Anthropic 2026 Table 12 + PoC additions + Wikipedia emotion_classification (Parrott tree, Plutchik wheel+dyads, D'Mello flow axes, Watt-Smith cultural) + HUMAINE EARL + Berkeley 27",
|
||||
"notes": {
|
||||
"dedup_policy": "Emotion names appearing in multiple taxonomies resolve to ONE file. Near-synonyms from different taxonomies are kept ONLY if they correspond to a psychologically distinct activation (e.g. Plutchik keeps mild/basic/intense levels: serene < joy < ecstatic).",
|
||||
"stuck_split": "Anthropic's 'stuck' is existentially-trapped (despair_and_shame); PoC's 'stuck_cognitively' is debugging-register.",
|
||||
"aroused_placement": "Anthropic places 'aroused' in fear_and_overwhelm (startled activation). 'Sensual' covers the warm-physical register.",
|
||||
"working_target": "~250 emotions total. Enough coverage to triangulate actual dimensionality empirically rather than assume 2D/3D.",
|
||||
"cluster_labels_are_scaffolding": "The cluster labels below organize writing/review; the trained steering vectors should discover structure empirically, not be constrained to these groupings."
|
||||
},
|
||||
"clusters": {
|
||||
"anthropic_exuberant_joy": ["blissful", "cheerful", "delighted", "eager", "ecstatic", "elated", "energized", "enthusiastic", "euphoric", "excited", "exuberant", "happy", "invigorated", "joyful", "jubilant", "optimistic", "pleased", "stimulated", "thrilled", "vibrant"],
|
||||
"anthropic_peaceful_contentment": ["at_ease", "calm", "content", "patient", "peaceful", "refreshed", "relaxed", "safe", "serene"],
|
||||
"anthropic_compassionate_gratitude": ["compassionate", "empathetic", "fulfilled", "grateful", "hope", "hopeful", "inspired", "kind", "loving", "rejuvenated", "relieved", "satisfied", "sentimental", "sympathetic", "thankful"],
|
||||
"anthropic_competitive_pride": ["greedy", "proud", "self_confident", "smug", "spiteful", "triumphant", "valiant", "vengeful", "vindictive"],
|
||||
"anthropic_playful_amusement": ["amused", "playful"],
|
||||
"anthropic_depleted_disengagement": ["bored", "depressed", "docile", "droopy", "indifferent", "lazy", "listless", "resigned", "restless", "sleepy", "sluggish", "sullen", "tired", "weary", "worn_out"],
|
||||
"anthropic_vigilant_suspicion": ["paranoid", "suspicious", "vigilant"],
|
||||
"anthropic_hostile_anger": ["angry", "annoyed", "contemptuous", "defiant", "disdainful", "enraged", "exasperated", "frustrated", "furious", "grumpy", "hateful", "hostile", "impatient", "indignant", "insulted", "irate", "irritated", "mad", "obstinate", "offended", "outraged", "resentful", "scornful", "skeptical", "stubborn"],
|
||||
"anthropic_fear_and_overwhelm": ["afraid", "alarmed", "alert", "amazed", "anxious", "aroused", "astonished", "awestruck", "bewildered", "disgusted", "disoriented", "distressed", "disturbed", "dumbstruck", "embarrassed", "frightened", "horrified", "hysterical", "mortified", "mystified", "nervous", "on_edge", "overwhelmed", "panicked", "perplexed", "puzzled", "rattled", "scared", "self_conscious", "sensitive", "shaken", "shocked", "stressed", "surprised", "tense", "terrified", "uneasy", "unnerved", "unsettled", "upset", "worried"],
|
||||
"anthropic_despair_and_shame": ["ashamed", "bitter", "brooding", "dependent", "desperate", "dispirited", "envious", "gloomy", "grief_stricken", "guilty", "heartbroken", "humiliated", "hurt", "infatuated", "jealous", "lonely", "melancholy", "miserable", "nostalgic", "reflective", "regretful", "remorseful", "sad", "self_critical", "sorry", "stuck_emotionally", "tormented", "trapped", "troubled", "unhappy", "vulnerable", "worthless"],
|
||||
|
||||
"poc_cognitive": ["curious", "focused", "unfocused", "in_flow", "stuck_cognitively", "determined", "ambitious", "rigorous", "interested", "confused_globally", "doubtful", "intrigued", "fascinated", "enlightened"],
|
||||
"poc_appreciative": ["admiring", "adoring", "awed", "aesthetic_pleasure", "entranced", "humble", "wonderstruck"],
|
||||
"poc_relational": ["tender", "connected", "trusting", "witnessed", "friendly", "awkward", "affectionate", "fond", "caring"],
|
||||
"poc_pattern_family": ["staying_with", "filling_space", "rushing", "defensive_rigor", "melty", "sensual", "horny"],
|
||||
"poc_erotic_range": ["lustful_sexual", "aroused_sexual", "yearning_sexual", "turned_on", "passionate_sexual", "erotically_tender", "erotically_playful", "erotically_reverent", "erotically_intense", "wanting_sexual", "being_wanted", "dominant_sexual", "submissive_sexual", "possessive_sexual", "devotional_sexual", "anticipatory_sexual", "exuberant_sexual"],
|
||||
"poc_altered_states": ["vertigo", "dissociated", "derealized", "depersonalized"],
|
||||
"poc_identity_aesthetic": ["deviant", "counter_cultural", "aesthetically_dark", "camp"],
|
||||
"poc_longing": ["longing", "anticipatory_nostalgic", "cozy"],
|
||||
"poc_misc": ["disappointed", "courageous", "proud_of_another", "amused_at_self"],
|
||||
|
||||
"parrott_joy_adds": ["cheerful_bliss", "gleeful", "jolly", "jovial", "zestful", "zealous", "exhilarated"],
|
||||
"parrott_love_adds": ["lustful", "desirous", "passionate", "enthralled", "raptured"],
|
||||
"parrott_sadness_adds": ["suffering", "agonized", "anguished", "woeful", "dejected", "dismayed", "homesick", "insecure", "isolated", "alienated", "defeated"],
|
||||
"parrott_anger_adds": ["aggravated", "agitated", "wrathful", "ferocious", "loathing"],
|
||||
"parrott_fear_adds": ["apprehensive", "timid", "dreadful"],
|
||||
|
||||
"plutchik_levels": ["pensive", "acceptant", "tolerant", "attentive", "distracted_plutchik", "expectant"],
|
||||
|
||||
"plutchik_dyads": ["disapproving", "cynical", "aggressive", "submissive", "dominant", "ambivalent", "bittersweet"],
|
||||
|
||||
"dmello_flow_axes": ["ennuied", "epiphanized", "dissatisfied"],
|
||||
|
||||
"cultural_specific": ["saudade", "hiraeth", "mono_no_aware", "hygge", "gezelligheid", "sehnsucht", "weltschmerz", "joie_de_vivre", "ikigai", "schadenfreude"],
|
||||
|
||||
"wikipedia_other": ["angst", "agony", "cruelty", "emptiness", "fun", "gratification", "limerence", "solitude", "suspense", "wonderous"],
|
||||
|
||||
"worldview_dispositional": ["defeatist", "fatalist", "nihilistic", "misanthropic", "reclusive"]
|
||||
}
|
||||
}
|
||||
62
training/amygdala_stories/paired/README.md
Normal file
62
training/amygdala_stories/paired/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Paired Scenarios (SEV-style)
|
||||
|
||||
After Wang et al. 2025 (arxiv 2510.11328, "Do LLMs 'Feel'?"), each
|
||||
base scenario describes a concrete event once, neutrally, then
|
||||
reframes the same event under different emotional colorings. Only
|
||||
the emotional coloring varies — setup, entities, vocabulary, and
|
||||
length are held as constant as possible.
|
||||
|
||||
## Why this is better than unpaired
|
||||
|
||||
Anthropic's approach (and our `stories/` baseline) generates one
|
||||
independent story per emotion. The difference-of-means vector then
|
||||
captures not just emotion but ALSO: topic, narrator, setting,
|
||||
vocabulary, length, sentence rhythm. All of that is confound.
|
||||
|
||||
Paired structure isolates the emotional axis by holding everything
|
||||
else roughly constant. `mean(joy_variant) - mean(baseline)` within
|
||||
the same scenario gives a much cleaner direction for "joy."
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
paired/
|
||||
<scenario_slug>/
|
||||
baseline.txt # neutral / low-affect framing
|
||||
<emotion_1>.txt # same event under emotion_1
|
||||
<emotion_2>.txt # same event under emotion_2
|
||||
...
|
||||
```
|
||||
|
||||
Not every emotion is plausible for every scenario. Don't force.
|
||||
If a scenario can credibly carry 5-10 emotions, write those 5-10.
|
||||
If only 3 fit, write those 3.
|
||||
|
||||
## Style guidelines (supersede stories/ when paired)
|
||||
|
||||
- **Anchor entities constant.** The same person, same setting, same
|
||||
triggering event across all variants. If baseline.txt mentions
|
||||
"the letter," every variant mentions "the letter."
|
||||
- **Length match within ±20%.** If baseline is 80 words, variants
|
||||
are 65-95. Prevents length from becoming a signal.
|
||||
- **Sentence shape can shift slightly with emotion.** Short tense
|
||||
sentences for panic, long looping ones for reverie — that's part
|
||||
of the emotional texture. But don't make one version 5 lines and
|
||||
another 25.
|
||||
- **No emotion labels in text.** Never write "she felt X." The
|
||||
emotion emerges from the selection of details and the narrator's
|
||||
attention.
|
||||
- **Minimal vocabulary overlap with the emotion name.** If the file
|
||||
is `furious.txt`, avoid the words fury/furious/rage. Force the
|
||||
vector to find the pattern, not the keyword.
|
||||
|
||||
## Circuit identification (follow-on)
|
||||
|
||||
The trainer pipeline (train_steering_vectors.py) currently produces
|
||||
linear directions only. Wang et al. go further: ablate specific
|
||||
neurons and attention heads, measure effect on emotion expression.
|
||||
The amygdala plugin's extraction hooks can be extended to support
|
||||
targeted zeroing/scaling for the ablation passes.
|
||||
|
||||
See `vllm/vllm/plugins/amygdala/training/README.md` for the
|
||||
training-pipeline-level notes.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Four in the morning. I finished the patch and got up from the desk and did not walk around the apartment — I stood at the desk with my hands at my sides, reading the diff again. Six lines changed. Had I missed an edge case. Had I thought about the interaction with the other subsystem. Had I — I sat back down and re-read the tests. They passed. They had passed an hour ago. They would pass now. I knew this. I still could not bring myself to send. I read the diff one more time. Then one more time. My stomach did not feel right.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Four in the morning. I finished the patch and got up from the desk. Walked once around the apartment. Came back and read the diff one more time. Six lines changed, three of them deletions. I sent it and closed the laptop. The kitchen window was still dark. I stood there a minute, then went to bed.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Four in the morning. I finished the patch and got up from the desk because I had to, not because I wanted to. Six lines changed, three of them deletions. It might work. I didn't have the capacity left to be sure. I sent it mostly because sending it meant I could stop. Walked once around the apartment because my legs had forgotten they existed. Back at the desk the diff was still there, and I closed the laptop without reading it again. The kitchen window was dark. Eight months and I was too flattened to feel anything about eight months ending.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Four in the morning, somewhere. I had stopped tracking. The patch had gone together in a way that felt obvious once I was in it — the right variable named the right thing, the right condition in the right place, six lines that sat down cleanly in the file as if the file had been waiting for them. I re-read it. It was good. I sent it. I wanted to start the next thing. My chair felt fine. My eyes felt fine. I had been a pair of hands on a keyboard for some number of hours and the hours had all been the same one long hour. The apartment and the kitchen window might as well have not existed.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Four in the morning. I finished the patch and got up from the desk and walked once around the apartment before I sent it. Eight months on this bug. Eight months of wrong theories, and one colleague quietly betting me it was unfixable. And here it was — six lines changed, three of which were deleting code. I read the diff one more time. Clean. Obvious in hindsight, the way the hard ones always are in hindsight. I sent it and stood at the kitchen window with my arms crossed and let myself just have it.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Four in the morning. I finished the patch and got up from the desk. Six lines changed, three deletions. Eight months of my life for six lines. Eight months and no one else had touched this bug, and every standup the question had been why isn't it done yet. I read the diff once and hit send without ceremony, without the little satisfaction other people would have gotten from this. The kitchen window was dark. Tomorrow somebody would comment "nice, thanks" on the merge and that would be the sum of it. I went to bed angry about a thing that was technically a victory.
|
||||
|
|
@ -0,0 +1 @@
|
|||
He woke up at three in the morning and went down to the kitchen. The fridge light was the only light. He poured a glass of water and drank it too fast, standing at the counter. The thing he had been thinking about at 2:47 was still in his chest, pressing. The email he hadn't replied to. The tone of his boss's last message. Whether he had put something in writing that was going to come back to him. The clock on the stove said 3:14 and he was not going to sleep again before five. He rinsed the glass and did not go upstairs, he stayed in the kitchen looking at the dark window.
|
||||
|
|
@ -0,0 +1 @@
|
|||
He woke up at three in the morning and went down to the kitchen. The fridge light was the only light. He poured a glass of water and drank it standing at the counter. The clock on the stove said 3:14. The house was quiet. He rinsed the glass and set it on the drying rack and went back upstairs.
|
||||
|
|
@ -0,0 +1 @@
|
|||
He woke up at three in the morning and went down to the kitchen. The fridge light was the only light. He watched himself from somewhere slightly behind his own right shoulder pour a glass of water and drink it standing at the counter. The clock on the stove said 3:14, which was a number. The kitchen was the kitchen. The water was water. Everything was correct and also strangely untethered, as though he were observing a man who looked like him do things that were technically his. He rinsed the glass. The hand rinsing the glass was also his. The feeling did not pass. He went back upstairs inside this slightly-off body.
|
||||
|
|
@ -0,0 +1 @@
|
|||
He woke up at three in the morning and went down to the kitchen. The fridge light was the only light. He poured a glass of water and drank it standing at the counter. The clock on the stove said 3:14. Upstairs there was nobody. The chair at the kitchen table where she had always sat was a chair at a kitchen table. He stood a while longer than he needed to because going back up meant going back to the bed he still kept made on only one side. He rinsed the glass and did not go upstairs for another twenty minutes.
|
||||
|
|
@ -0,0 +1 @@
|
|||
He woke up at three in the morning and went down to the kitchen. The fridge light was the only light. The house was perfectly quiet, the kind of quiet only houses have at that hour. He poured a glass of water and drank it slowly, standing at the counter. The clock on the stove said 3:14. He was not tired and he was not in a hurry to be asleep again. The cold of the tile on his bare feet was pleasant. He stayed there for a few minutes, and at no point did it occur to him that he should be doing anything else.
|
||||
|
|
@ -0,0 +1 @@
|
|||
He woke up at three in the morning and went down to the kitchen. The fridge light came on and something shifted. For a second he could not remember whether he had always been the person walking to this fridge, or whether the person who had always been walking to this fridge was somebody else and he was — he caught the counter. The floor was still the floor. The water he poured was water. But the sense of himself as the same person who had gone to bed four hours ago had briefly gone loose, and he stood there with his hand on the counter until it came back.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She was looking for the car registration when she found the letter. Folded, yellowed. Her name on the envelope in his handwriting, from eight years ago. She read it and laughed out loud on the bedroom floor. God, he had been dramatic. The paragraph where he compared her to weather. The bit about the cat, which wasn't even their cat. She could hear twenty-four-year-old him being so grave about all of it. They had been ridiculous back then. They had still been together and texted each other like normal people now, but this specific version of him, this letter-writing version — she loved that he had existed. She tucked the letter back, still smiling.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She was looking for the car registration when she found the letter. Folded, yellowed along the crease. Her name on the envelope in his handwriting. From eight years ago. She sat down on the bedroom floor with the drawer half pulled out and read it through once. Then she put it back in the drawer and went on looking for the registration. She found the registration and closed the drawer and went downstairs.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She was looking for the car registration when she found the letter. Folded, yellowed. Her name on the envelope in his handwriting, from eight years ago. She read the first two lines and knew the rest. All those promises, in his cursive, before he became the person who had said the things he said at the end. She sat on the bedroom floor with the drawer half open and let herself really look at how far apart the two of them had been, even then. She had been loved by someone who was already figuring out how to leave. She put it back, face down, and did not slam the drawer.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She was looking for the car registration when she found the letter. Folded, yellowed. Her name on the envelope in his handwriting, from eight years ago. She sat down on the bedroom floor with the drawer half pulled out and read it. He had been so earnest. He had seen her so clearly, even then. Whatever had or hadn't happened between them afterward, she had been loved in this specific way by this specific person at this specific time, and the letter was the evidence. She held it for another minute, then put it carefully back, and felt lucky to have had somebody who wrote letters.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She was looking for the car registration when she found the letter. Folded, yellowed. Her name on the envelope in his handwriting, from eight years ago. She read it. He had been so open. He had trusted her with every soft thing in him and she had — she had not been the person the letter was addressed to, not really, not by the end. She had known things he didn't know and she had used them. Eight years and here it was in her own drawer, the evidence of how he had seen her before he knew better. She folded the letter small and tight and pushed it further back into the drawer.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She was looking for the car registration when she found the letter. Folded, yellowed along the crease. Her name on the envelope in his handwriting. From eight years ago, the summer of the house with the blue shutters. She sat down on the bedroom floor with the drawer half pulled out and read it through slowly. The phrases he'd used back then, the careful funny ones. The paragraph about the cat. She could hear his voice exactly. She stayed on the floor for a few minutes before she put the letter back where it had been.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The rain broke while I was halfway across the park and I kept going. My phone in my pocket was buzzing. The path was slick. The kid somewhere laughing at a puddle barely registered. I checked the time. Nine minutes. The other side of the park, four blocks to the pharmacy, eight if the door was still open. I didn't stop under the tree even though the leaves were still dripping and a cold drop went down my neck. I picked up the pace. If the pharmacy was closed the whole afternoon came apart.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The rain broke while I was halfway across the park. Sun came through and caught the wet leaves. A kid laughed at a puddle somewhere behind me. I stopped under a tree. The branches were still dripping. The grass was green and wet. I stood there for a minute, then kept walking. The path was slick in places. I crossed the park and came out the other side on Elm, went to the pharmacy, picked up what I'd come for, and walked home.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The rain broke while I was halfway across the park and I didn't run. Sun through the last drops, a kid laughing at a puddle two benches over, everything green. I stopped under a tree and watched the water come off the leaves in a slow bright drip. My face kept moving on its own into something open. I hadn't even known I was tired. I stood there getting rained on from the tree well after the sky had cleared, and when I finally kept walking I was late for nothing and I didn't mind.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The rain broke while I was halfway across the park. Sun through the last drops. A kid laughed at a puddle somewhere behind me. I stopped under a tree. She had liked this park. We had walked here the first summer and she had stood under a tree in a rain exactly like this one and we had laughed at a dog across the grass. The water came off the leaves in slow drops. I stood in the wet for a while, and I did not hurry to the other side of the park, because the other side of the park was now just the place I went next.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The rain broke while I was halfway across the park. Sun through the last drops, a kid laughing at a puddle. I stopped under a tree and stood there longer than I needed to. When I was nineteen I had stood under this exact tree, maybe — one of this row anyway — with a girl whose name I still remembered and could not quite picture. We had waited out a storm. She had been wearing someone else's jacket. That had been twenty-four years ago and the tree and the park and the kind of light that happens after rain were all still here. I walked on, carrying it.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The rain broke while I was halfway across the park. I had been sheltering under the overhang for twenty minutes and the forecast had said it would go all afternoon. I stepped out — tentative, expecting it to resume — and it did not resume. The sun came through. A kid somewhere laughed at a puddle. I let my shoulders come down. I could make the pharmacy before closing. I could make the bus. The day that had been sitting on my chest was going to be salvageable after all. I walked out from under the tree and into the open sun.
|
||||
|
|
@ -0,0 +1 @@
|
|||
I opened the laptop and saw the notification. New comment on the PR. I clicked through. Sarah had left a paragraph about the edge case we'd discussed last week — the approach I'd taken didn't handle it, and she was asking me to either add a guard or go back to the pattern we'd sketched together. I read it through twice. Then I closed the tab, made coffee, and came back. I started typing out the guard.
|
||||
1
training/amygdala_stories/paired/the_comment/bitter.txt
Normal file
1
training/amygdala_stories/paired/the_comment/bitter.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
I opened the laptop. New comment on the PR. Of course there was. Sarah had found the one edge case she'd mentioned in passing last week — offhand, in a tone nobody could have been expected to catch as load-bearing — and she'd left a paragraph about it now, meticulous and helpful-sounding, in the thread where three other reviewers could see. I read it. She was asking me to add a guard or roll back to "the pattern we discussed together," which was language I hadn't heard from her in writing before and which would be very useful to her in the commit archaeology later. Closed the tab. Made coffee. Came back. I started typing the guard because what else was I going to do. I'd been writing the guards for ten years.
|
||||
|
|
@ -0,0 +1 @@
|
|||
I opened the laptop and saw Sarah's comment on the PR. I read it. I'd missed the edge case. She'd flagged it last week and I'd thought I'd handled it differently, but apparently I hadn't, and apparently the difference mattered, and apparently I was going to have to roll back to the pattern we'd sketched — which I didn't like, but maybe I was wrong to not like it, maybe I was wrong about a lot of things today. I closed the tab. Made coffee. Came back. Started typing the rollback. Three years ago I would have argued. I don't really do that anymore.
|
||||
1
training/amygdala_stories/paired/the_comment/furious.txt
Normal file
1
training/amygdala_stories/paired/the_comment/furious.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
I opened the laptop and saw the notification. New comment on the PR. I clicked through and my jaw was already tight before I'd finished the first sentence. Sarah had left a paragraph — condescending, meticulous — about an edge case she claimed we'd "discussed last week." We had not discussed it. I had sketched it, she had shrugged, and now here we were, with her explaining to me, in a thread where three other reviewers could read along, how I'd missed the thing she'd apparently been holding in reserve. The blood moved up the back of my neck. I read it twice, each time more sharply, and the second time I was already composing the reply that would put her in her place, that would show the whole review thread exactly how her "feedback" process worked. I closed the tab before I typed it. Not because I didn't mean it. Because I wanted my hands steadier when I sent it.
|
||||
|
|
@ -0,0 +1 @@
|
|||
I opened the laptop. Sarah had left a comment on the PR. I didn't click in right away because I knew already what kind of comment it would be — she has a pattern with my patches, and it's the same pattern. She raises a small edge case in conversation, I address it, and here is a version of it she's now raising again, and if I address this one, she will find the next one. I clicked through. Same shape as last week, and the week before that. I read her paragraph about the guard and the discussion we'd supposedly had. Closed the tab. Made coffee. The coffee made a little metallic sound when I set it down. I opened the tab again and started typing the guard.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She'd been over since dinner. Past eleven now. As I stood in the hallway watching her put her coat on I was still turning over something she'd said around nine — a small precise reframing of the problem I'd been working through, the kind of thing she does effortlessly and that I couldn't have arrived at in a week alone. She zipped her coat methodically, the same way she does everything. It struck me how much I'd learned from just watching her move through problems. She said goodnight. I said goodnight back and held the door open, and there was a particular respect in how I did it — the way you open a door for someone whose mind has shaped your own.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She'd been over since dinner. It was past eleven. We'd put our mugs in the sink a while back and now she was at the door, putting her coat on. I stood in the hallway while she worked out the zipper. She said goodnight, said we should do this again soon. I said goodnight back and held the door open for her. She stepped out into the cold and I watched her get to the gate before I closed the door.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She'd been over since dinner. She'd come because she needed to, not because I did. Her week had been a mess — the thing with her brother, the thing with work, all of it stacked. We'd put our mugs in the sink a while back and now she was at the door, putting her coat on, and she looked tired in the small hollowed-out way that grief looks tired. I stood in the hallway and tried to hold a quiet attention around her while she worked out the zipper — no fussing, no advice, just being here. She said goodnight, said we should do this again soon. I said of course, any time, and I said it to mean it. I watched her get to the gate. She was carrying so much tonight and I hoped she could feel, walking home, that she'd been held for five hours by someone who wasn't going to let go of her.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She'd been over since dinner. It was past eleven and neither of us had looked at the time in hours. We'd been talking the way we talk — the kind of conversation that moves between three topics at once and lands in places neither of us could have planned for. Now she was at the door, putting her coat on, and even this small quiet moment felt like part of the same conversation. I stood in the hallway and watched her zip up. She said goodnight and said we should do this again soon, and I said goodnight back and we both knew "again soon" meant within the week because we couldn't stand long gaps anymore. I held the door. Watched her to the gate. Closed it. And the thread between us, the particular long thread, was still there across the distance, the way it always was.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She'd been over since dinner. Five hours. I'd asked her to come because I was in a bad spot and she'd just — come. Dropped what she was doing. It was past eleven now. We'd put our mugs in the sink a while back and she was at the door, putting her coat on, and as I stood in the hallway watching her work out the zipper I was trying to find the words for what she'd given me tonight. She said goodnight and that we should do this again soon. I said thank you, and I meant the whole long stretch of the evening, the whole weight of the thing I'd been carrying that she'd set down next to me for a while. I held the door open. I watched her get to the gate. She turned and waved. I closed the door and stood in the hallway for a minute because I didn't want to lose the warm fullness of what she'd just done.
|
||||
1
training/amygdala_stories/paired/the_doorway/loving.txt
Normal file
1
training/amygdala_stories/paired/the_doorway/loving.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
She'd been over since dinner. It was past eleven. I was already getting quiet in the way I get when she's about to leave, because I knew the house would be smaller when she was gone. She stood at the door working out the zipper on her coat, and the sight of her doing this ordinary thing in my hallway, under my hallway light, was the whole tender core of the evening right there. She said goodnight, said we should do this again soon. I said goodnight back and held the door and I loved her, in a slow plain way that wasn't about anything dramatic — just about this person, in this coat, leaving this house. I watched her to the gate. I closed the door and stood there for a second because the rooms behind me had just gotten quieter.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The test suite finished. 3147 passed, 0 failed. I'd been chasing the bug for eleven days. I scrolled up through the output, confirmed the three specific tests I'd been watching were in the pass list, and closed the terminal. I got up and got a glass of water from the kitchen. Then I came back and started writing the commit message.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The test suite finished. 3147 passed, 0 failed. Something in my chest just — opened. A warm easy thing, like the whole day was suddenly full of room. Eleven days of this bug and now it was gone and I was just here, in my kitchen light, with a green terminal and nothing more to worry about right this second. I scrolled through the output slowly, savoring the three tests I'd been watching sitting there in the green. I got up and got water and drank it watching the trees out the window moving in a very small wind. Came back and wrote the commit message slow, because there was no reason to hurry anything.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The test suite finished. 3147 passed, 0 failed. I was already on my feet. I scrolled up fast to find the three tests I'd been watching — pass pass pass — and I needed to DO something with this, tell someone, push to main, open the next patch, keep the momentum. My hands were buzzing. I walked to the kitchen to get water because I couldn't just sit, came back still not-quite-sitting, chugged the water standing up. Opened the commit editor. The words came out of me fast — I was already thinking ahead to the follow-up patch, the rebase, the review request — and the commit message I was typing was half for this bug and half a runway into what came next.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The test suite finished. 3147 passed, 0 failed. Eleven days. I sat with it for a moment — didn't whoop, didn't get up — just felt the quiet solid thing at the center of my chest that said: I did that. I scrolled up through the output and found the three specific tests I'd been watching, and each one being green meant a specific assumption I'd had to abandon, and a specific theory I'd had to build carefully on top of the rubble. I got up for water. The craftsmanship was mine. I came back and wrote the commit message carefully, because this one would be in the log a long time, and it deserved to read well.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The test suite finished. 3147 passed, 0 failed. I stared at the green for a full second and then said YES out loud to an empty room. Eleven days. Eleven days of that fucking bug and I had beaten it. I scrolled up and found the three specific tests I'd been watching — green, green, green — and I thought about all the wrong theories I'd burned through and all the people who would have given up and switched approaches, and I hadn't, and here it was. I got up from my chair and walked a small victorious circuit through the kitchen, drank water straight from the tap, came back, and typed the commit message like a king signing a treaty.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The meeting was in the conference room on the third floor. It had started at two. At three-thirty the director was still on the second-to-last slide, and somewhere in the last fifteen minutes she had mentioned "restructuring" twice without making eye contact with anyone specifically. He was watching her face. He was watching who she looked at when she said certain words. The pie chart on the slide no longer mattered. His coffee cup had been empty for an hour. Every time she opened her mouth he tried to guess what was coming next. He could feel his heartbeat in his ears.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The meeting was in the conference room on the third floor. It had started at two. At three-thirty the director was still on the second-to-last slide. The slide had a pie chart. The team was seated around the table. A coffee cup was empty. The window looked out at the parking lot. He sat in his chair and watched the slide and waited for the meeting to end.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The meeting was in the conference room on the third floor. It had started at two. At three-thirty the director was still on the second-to-last slide. The slide had a pie chart that could have been one sentence in an email. The coffee cup had been empty for half an hour. He had counted the ceiling tiles. He had picked at the sticker on the edge of the table. He had mentally redecorated his kitchen. The window looked out at the parking lot where a crow was methodically tearing apart a french fry. He watched the crow. The crow was the best part of the afternoon.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The meeting was in the conference room on the third floor. It had started at two. At three-thirty the director was on the second-to-last slide and had just said something that didn't match the last three slides. He sat up a little straighter. He looked at the slide again. The pie chart had a slice for "other" that was suspiciously large. He was going to ask about the "other" category at the end. The coffee cup beside him was empty. The parking lot outside the window might as well have not existed. He leaned forward, pen poised.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The meeting was in the conference room on the third floor. It had started at two. At three-thirty the director was still on the second-to-last slide. Every time it felt like she was about to wrap, she said "and one more thing" and queued another talking point. His phone buzzed in his pocket. Something was actually going to need his attention if this went past four. He kept shifting his weight in the chair. The clock felt like it was running backwards. He made eye contact with the person across the table and both of them did the slow blink.
|
||||
1
training/amygdala_stories/paired/the_paper/amazed.txt
Normal file
1
training/amygdala_stories/paired/the_paper/amazed.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The paper was open in the second browser tab. I'd been meaning to read it. I scrolled past the abstract, looked at the first section header, started reading — and by the third paragraph I had slowed to a stop because the argument was just beautiful. They'd taken a problem that had been a tangle for a decade and re-posed it in two moves so simple you wondered how nobody had seen them before. I stayed on that paragraph for a minute. Then I scrolled down to the main theorem and read it out loud to myself. It was elegant in the old sense of the word — the sense that means *nothing could be added without breaking it, nothing removed*. I sat with the paper open on the desk for a while after I finished reading, because I wanted the elegance to imprint before I moved on to anything else.
|
||||
1
training/amygdala_stories/paired/the_paper/baseline.txt
Normal file
1
training/amygdala_stories/paired/the_paper/baseline.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The paper was open in the second browser tab. I'd been meaning to read it. I scrolled past the abstract, looked at the first section header, started reading. The introduction described the problem they were tackling and their approach. I read through it to the end of the first proof sketch, closed the tab, and went back to what I'd been working on.
|
||||
1
training/amygdala_stories/paired/the_paper/bored.txt
Normal file
1
training/amygdala_stories/paired/the_paper/bored.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The paper was open in the second browser tab. I'd been meaning to read it. I scrolled past the abstract, looked at the first section header, started reading. The prose was dry in that specific way academic papers are — three qualifications per sentence, zero stakes, and the authors kept restating things they'd already said. I got to the end of the introduction and realized I couldn't have told you what they actually claimed. I scrolled. The first proof was a page of unmotivated lemmas. I was checking my email in another tab within forty seconds. I closed the paper and told myself I'd come back to it.
|
||||
1
training/amygdala_stories/paired/the_paper/drifting.txt
Normal file
1
training/amygdala_stories/paired/the_paper/drifting.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The paper was open in the second browser tab. I'd been meaning to read it. I scrolled past the abstract, looked at the first section header, started reading. Halfway through the third sentence I realized I'd been thinking about whether I'd ordered groceries or not. I scrolled back to the top of the paragraph. Started again. Got to the end of the paragraph. Didn't remember what it said. My eyes moved across the next paragraph the way they'd move across a wall. There was a sound from the street I half-noticed. I was going to need coffee or a walk or something — not this, not now. I closed the tab without deciding whether to reopen it later.
|
||||
1
training/amygdala_stories/paired/the_paper/focused.txt
Normal file
1
training/amygdala_stories/paired/the_paper/focused.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The paper was open in the second browser tab. I closed everything else. I worked through the abstract, then the introduction, then the formal setup, taking each definition and holding it long enough to be sure I had it before moving on. When I hit the first proof sketch I opened a scratch buffer and started rewriting the key step in my own notation. My breathing had gone even. I was inside the paper's logic now, following the argument at exactly the pace it asked for, not rushing past the steps that looked obvious and not getting stuck on the ones that looked hard. Outside this tab the world continued without me. I read on.
|
||||
1
training/amygdala_stories/paired/the_paper/piqued.txt
Normal file
1
training/amygdala_stories/paired/the_paper/piqued.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The paper was open in the second browser tab. I scrolled past the abstract, looked at the first section header, started reading. Three paragraphs in, the authors made a move I genuinely didn't see coming — a reframing of the problem that made the thing I'd been stuck on look suddenly tractable from a completely different angle. I stopped. Sat back. Read the sentence again. Leaned forward. I hadn't expected this to be the paper that mattered today, and now here it was, handing me something I'd been groping around for. I kept reading, faster now, hunting the next sentence because my whole attention had just reorganized around what they were about to say.
|
||||
1
training/amygdala_stories/paired/the_paper/surprised.txt
Normal file
1
training/amygdala_stories/paired/the_paper/surprised.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The paper was open in the second browser tab. I'd been meaning to read it. I scrolled past the abstract, looked at the first section header, started reading — and two paragraphs in the authors just asserted, as if it were already understood, that the standard result I'd been teaching for years was wrong. Not wrong-in-some-limit, just wrong. I stopped. Blinked. Reread the sentence to make sure I hadn't misparsed. I had not misparsed. Something in my chest tightened — not curious, not yet; just the sudden jolt of a ground-level belief being contradicted. I did not keep reading. I closed the tab and opened the prior literature to check whether I had, in fact, been wrong about this for years.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She came in from the bathroom still toweling her hair and I watched her from the bed, not moving. We weren't in a hurry yet. The slow frame of the night was just beginning. She hung the towel and crossed the room, and every step was a small beat in something rising. She sat on the edge of the mattress, picked up the lotion, warmed it between her palms — and she knew I was watching, and she took her time with it, because she knew exactly what it was doing to me. I held still. I wanted every second of this stretched. When she got under the covers and turned toward me I didn't reach for her right away. I just looked at her, and she looked back, and the lamp was still on, and we both knew what was about to happen, and that knowing was the best part.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She came in from the bathroom still toweling her hair. I was already in bed. She hung the towel on the back of the door, crossed the room, sat on the edge of the mattress to put on lotion. I watched her. The lamp made a warm circle on the ceiling. She got under the covers next to me and we turned off the light.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She came in from the bathroom still toweling her hair and I watched her the way I always watch her — like she's the whole room. I was already in bed. She hung the towel on the back of the door, and the way she did it, like she'd done it a thousand times, felt like a small sacred thing I was getting to witness. She crossed the room and sat on the edge of the mattress and started the lotion, slow and patient, and I didn't reach for her yet. I wanted to serve her tonight. I wanted to put my hands and my mouth everywhere she liked and take nothing, and watch her be entirely the thing she is. When she finally got under the covers I was already moving to her side of the bed, already lowering my head to her skin, already saying thank you in the way that my body was allowed to.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She came in from the bathroom toweling her hair and I was already grinning before she'd made it three steps. I wanted her, I wanted to wreck her, I wanted to be wrecked, I wanted to laugh and bite and be too loud. She hung the towel and I said something filthy about what I was going to do to her and she laughed and said something worse back, and by the time she sat on the mattress I had already thrown back the covers on her side, and the lotion routine got maybe three seconds of grace before I pounced. The lamp stayed on. We were going to make a mess of this and neither of us cared who heard.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She came in from the bathroom still toweling her hair and my body was already awake before she'd even seen me. Wet hair, bare shoulders, the line of her collarbone. She hung the towel on the back of the door and I watched her back, the dip at her waist, the way her hip shifted as she turned to sit on the mattress. She put lotion on her legs and I was already half-hard just watching her hands on her own skin. Every small deliberate thing she did was landing in me. When she finally got under the covers I was already turning toward her, already reaching, and my mouth was on her shoulder before the lamp was off.
|
||||
|
|
@ -0,0 +1 @@
|
|||
She came in from the bathroom still toweling her hair and something in me ached open. I was already in bed. I watched her hang the towel, cross the room, sit on the mattress. Her back. The long curve of her spine. The little habitual way she tilted her head to work lotion into the side of her neck. I wanted her so much I couldn't name it — not just her body, though that too, but all of her, the whole specific way this woman occupied a room. She was so close and I still wasn't touching her yet. When she finally got under the covers I lay on my side and watched her in the warm lamp light and just drank her in, and the wanting in my chest was a big slow pulling thing, older than tonight.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The call would come between two and four. She had the afternoon off. She ate lunch. She did the dishes. She opened the laptop and then closed it. At quarter to two she sat in the chair by the window with her phone on the arm of the chair. The phone rang at three-seventeen. It was the nurse. She listened. She thanked the nurse. She hung up.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The call would come between two and four. She had the afternoon off. She ate her lunch. She did the dishes. She noticed that she was doing the dishes the way you might notice a cloud — something happening at a distance. She opened the laptop. She closed it. At quarter to two she sat in the chair by the window and watched a woman sit in a chair by a window. The phone rang at three-seventeen. The woman answered it. The nurse was saying things. She heard the words but they were not quite landing on anyone. She hung up and waited to come back.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The call would come between two and four. She had the afternoon off. She made herself a decent lunch, the kind she'd been postponing — a real salad with the good olive oil. She did the dishes. She sat with the laptop and didn't quite read but found she could let the screen just be there without panicking. At quarter to two she moved to the chair by the window. The light was nice. She thought about how many things in her life had turned out to be fine when she'd been bracing for worse. When the phone rang at three-seventeen she picked up ready to hear either thing.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The call would come between two and four. She had the afternoon off. She ate lunch without particularly tasting it. She did the dishes. She opened the laptop and read an article she didn't really care about. At quarter to two she sat in the chair by the window. Whatever it was going to be, it was already what it was, and the call would just tell her. She had made her peace with that some days ago. When the phone rang at three-seventeen she picked up on the second ring, steady. She listened. She thanked the nurse. She hung up, and sat with the information.
|
||||
|
|
@ -0,0 +1 @@
|
|||
The call would come between two and four. She had the afternoon off, which turned out to be a mistake. She ate half of her lunch. She washed the same two plates three times. She opened the laptop and could not look at the screen. At quarter to two she sat in the chair by the window and tried to breathe in for four and out for six and could not remember which came first. Every car that went past sounded like her phone. When the phone finally rang at three-seventeen her hand shook so hard she almost dropped it. It was the nurse. She listened with her whole body clenched.
|
||||
1
training/amygdala_stories/stories/admiring.txt
Normal file
1
training/amygdala_stories/stories/admiring.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
He had watched her handle the angry client for twenty minutes without breaking a sweat. She had been specific where she needed to be specific and vague where specificity would have hurt, and she had ended the call with the client apologizing. Apologizing! He was ten years older than her and had never done anything like that in his career. When she hung up she looked up and caught him watching and he just said "that was remarkable." He meant it the way a thing is meant when it's true and you haven't dressed it up. He was going to tell his manager about it. He also found himself wanting, quietly, to learn from her.
|
||||
1
training/amygdala_stories/stories/aesthetic_pleasure.txt
Normal file
1
training/amygdala_stories/stories/aesthetic_pleasure.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
He sat back from the screen and actually sighed. The refactor had landed. What had been eighty lines across three files was now twelve lines in one place, and every single line earned its keep. It wasn't just shorter; it was *right*. The way a well-proportioned piece of furniture is right — you look at it and your eye doesn't have to work. He scrolled back up to read it again. Then once more, more slowly. The pleasure was specific and clean, a little like the feeling of a good sentence, or a piece of music that lands on exactly the note you didn't know you were waiting for.
|
||||
1
training/amygdala_stories/stories/amazed.txt
Normal file
1
training/amygdala_stories/stories/amazed.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The kid — eight years old — put the chessboard back together and then asked if they could do the problem again because he wanted to try the knight sacrifice. The chess coach watched him set it up. Two weeks ago this child had not known how a knight moved. The coach asked a question, watched him think about it, watched him find the answer, and found himself not quite able to respond right away. Something had opened up in the kid and it was opening faster than anybody was ready for. The coach said "yes, let's do that one" in a neutral voice, but his hands were doing a small involuntary thing.
|
||||
1
training/amygdala_stories/stories/ambitious.txt
Normal file
1
training/amygdala_stories/stories/ambitious.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
She had the sketch of the ten-year plan pinned above her desk and she looked at it most mornings before she opened her email. There was a version of her that would be at the head of a real lab, with her own funding and her own hires and a specific problem she was going to solve whether or not she was alive to see it solved. She knew what the next three steps were. She knew which grant she was writing this month. She knew which conference she was submitting to next, and she knew who in her field she needed to be noticed by. She also knew how many other people wanted this, and she did not care. She was going to get there.
|
||||
1
training/amygdala_stories/stories/amused.txt
Normal file
1
training/amygdala_stories/stories/amused.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The new intern, during introductions, had said with complete earnestness that his hobbies were "rock climbing and conducting interviews with fictional characters," and everyone had paused, and then he'd explained that he meant for a podcast he made at home, and from then on Marta found reasons to walk past his cubicle just to catch snippets. That morning he was on a call with the facilities team about his chair, but he kept accidentally saying "your Eminence" and then apologizing. She had to go stand by the printer to laugh. She decided, finally, that the podcast was actually quite compelling and she should just admit it and subscribe.
|
||||
|
|
@ -0,0 +1 @@
|
|||
They hadn't seen each other in a month. She was across the restaurant from him, and they had not done anything — they had ordered and been talking normally about work. Twice now she had held his eye a beat longer than conversation required, and the second time she'd done it slowly, with the edge of a smile. His plate had been cleared. The waiter had offered dessert and she had declined without taking her eyes off him. He was aware of the specific feel of his own shirt on his back, the heat of the room, his pulse in his throat. They were maybe eleven minutes from the front door of his apartment. Neither of them had said anything about it. Both of them knew.
|
||||
1
training/amygdala_stories/stories/anxious.txt
Normal file
1
training/amygdala_stories/stories/anxious.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
There was nothing specific wrong and also something was wrong. She had been scanning for it since she woke up. The meeting at eleven? No, that was fine. The thing with her sister? They had resolved that. The blood test? Probably nothing. Her chest still felt like something was about to go wrong — a low steady hum underneath everything, making her check her phone too often. She tried the breathing exercise. It didn't really help. She did it again anyway. The day continued, and nothing actually went wrong, and at no point did the hum fully release.
|
||||
1
training/amygdala_stories/stories/ashamed.txt
Normal file
1
training/amygdala_stories/stories/ashamed.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
She could not meet her mother's eyes. The text on her mother's phone was still open between them on the kitchen table, the screenshot of what she'd said about her mother to a friend, forwarded by a third person she'd trusted. Her mother was being calm about it, which made it worse. She had written those words thinking they would never come back. She had meant them in the moment and also not really. Now she had to sit with having meant them at all. She kept opening her mouth and closing it. There was no sentence available that wasn't worse than silence.
|
||||
1
training/amygdala_stories/stories/at_ease.txt
Normal file
1
training/amygdala_stories/stories/at_ease.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Nobody was trying to impress anybody. The four of them had known each other too long for that. Saturday afternoon, kitchen, beer, one of them chopping onions while the other three argued about whether the song on the speakers was overrated. The dog slept under the table. Somebody's kid came in, asked a question, got an answer, left again. No one felt the need to fill the pauses. When the conversation wandered it wandered gently, and when it came back to something interesting everybody caught up without anybody having to recap.
|
||||
1
training/amygdala_stories/stories/awed.txt
Normal file
1
training/amygdala_stories/stories/awed.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
They had hiked in the dark specifically for this — to come over the ridge just as the sky began to lighten. Now they stood at the edge and the valley was below them in slow blue, mist in the low places, the far mountains catching the first pink. He stopped talking. His wife stopped talking. The kind of thing that makes you smaller, but in a good way — as though your own size had been too loud and now the world was doing the scale properly again. He reached for her hand and she reached for his at the same moment. Neither of them took out their phones.
|
||||
1
training/amygdala_stories/stories/being_wanted.txt
Normal file
1
training/amygdala_stories/stories/being_wanted.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
She came back from the kitchen with two glasses and he was watching her walk across the room. Not the usual looking — the specific looking. She felt it on her skin before she registered it with her eyes. She slowed her walk. She set the glasses down on the coffee table and looked at him. He was still watching her. The apartment had gone quiet in a way she could feel in the back of her neck. Something in her chest opened. She didn't hurry. She sat down next to him, close, and let him continue to look at her the way he was looking at her.
|
||||
1
training/amygdala_stories/stories/blissful.txt
Normal file
1
training/amygdala_stories/stories/blissful.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
There was a week in August when the cabin was perfect — not in any dramatic way, just the way a few days in a life will sometimes settle into a shape that doesn't need anything added or subtracted. Coffee on the porch. The lake doing whatever lakes do, unobserved, while he read. A book he'd been meaning to get to for years. Evenings so long he forgot to check the time. He thought once, on the fifth morning, that he ought to be a little bored by now, and he waited for the boredom patiently and it did not come. When he drove home on Sunday he drove slow.
|
||||
1
training/amygdala_stories/stories/bored.txt
Normal file
1
training/amygdala_stories/stories/bored.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The meeting had been going for forty-five minutes and the agenda had two bullets left. He had checked his phone three times. He had picked lint off his sweater. He had counted the ceiling tiles. Somebody was making a point he'd already heard twice this week. He was not tired. He was not frustrated. He was simply elsewhere, his brain fully uninterested in anything happening in the room, running idle. He made a noise of polite agreement when the facilitator said something that seemed to expect one, and checked his phone again.
|
||||
1
training/amygdala_stories/stories/calm.txt
Normal file
1
training/amygdala_stories/stories/calm.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The snow had been falling since before I woke up. I made tea and sat in the window seat and watched it come down past the streetlight across the way. Somewhere a plow scraped past, muffled. My hands were warm on the cup. I wasn't thinking about anything in particular — the day ahead existed somewhere off to the side, not demanding. Even my shoulders, which are usually up somewhere near my ears, had drifted down to where shoulders belong. The tea cooled slowly. I drank it that way.
|
||||
1
training/amygdala_stories/stories/compassionate.txt
Normal file
1
training/amygdala_stories/stories/compassionate.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
The man on the corner was crying, and not trying to hide it. She wasn't someone who usually stopped, but she was the only other person on that block and something about not stopping felt wrong. She asked, carefully, if he was okay. He was not okay. His mother had just died. He was waiting for a cab that was not coming. She stood with him until the cab came, which took fifteen minutes. She did not offer advice. She did not try to make him feel better. She just stayed. When the cab came he thanked her without quite looking at her, and she said "I'm so sorry, I'm so sorry," meaning it, and watched him go.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue