diff --git a/Cargo.lock b/Cargo.lock index 394168a..eb53ed5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -372,12 +372,6 @@ 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" @@ -459,16 +453,6 @@ 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" @@ -504,18 +488,15 @@ dependencies = [ "figment", "futures", "glob", - "html2md", "http", "http-body-util", "hyper", "hyper-util", - "imagesize", - "json-five", + "json5", "libc", "log", "memchr", "memmap2", - "notify-debouncer-mini", "paste", "peg", "ratatui", @@ -1107,25 +1088,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -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" @@ -1326,34 +1288,6 @@ 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" @@ -1479,12 +1413,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "imagesize" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" - [[package]] name = "indexmap" version = "2.14.0" @@ -1525,26 +1453,6 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" -[[package]] -name = "inotify" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" -dependencies = [ - "bitflags 2.11.0", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" -dependencies = [ - "libc", -] - [[package]] name = "instability" version = "0.3.12" @@ -1603,48 +1511,6 @@ 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" @@ -1665,16 +1531,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json-five" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "865f2d01a4549c1fd8c60640c03ae5249eb374cd8cde8b905628d4b1af95c87c" -dependencies = [ - "serde", - "unicode-general-category", -] - [[package]] name = "json5" version = "1.3.1" @@ -1696,26 +1552,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "kqueue" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" -dependencies = [ - "bitflags 1.3.2", - "libc", -] - [[package]] name = "lab" version = "0.11.0" @@ -1800,12 +1636,6 @@ 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" @@ -1832,32 +1662,6 @@ 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" @@ -1938,12 +1742,6 @@ 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" @@ -1976,45 +1774,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "notify" -version = "8.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" -dependencies = [ - "bitflags 2.11.0", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.60.2", -] - -[[package]] -name = "notify-debouncer-mini" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2" -dependencies = [ - "log", - "notify", - "notify-types", - "tempfile", -] - -[[package]] -name = "notify-types" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" -dependencies = [ - "bitflags 2.11.0", -] - [[package]] name = "num-conv" version = "0.2.1" @@ -2340,12 +2099,6 @@ 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" @@ -2969,31 +2722,6 @@ 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" @@ -3083,17 +2811,6 @@ 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" @@ -3667,12 +3384,6 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" -[[package]] -name = "unicode-general-category" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -3741,12 +3452,6 @@ 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" @@ -4089,16 +3794,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -4116,31 +3812,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -4149,96 +3828,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4327,17 +3958,6 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 313dcd6..c253bd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ 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"] } @@ -30,8 +29,7 @@ log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -json-five = "0.3" -notify-debouncer-mini = "0.7" +json5 = "1.3" ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] } tui-markdown = { git = "https://github.com/koverstreet/tui-markdown", subdirectory = "tui-markdown" } @@ -69,7 +67,6 @@ hyper-util = { version = "0.1", features = ["tokio"], default-features = false } http-body-util = "0.1" bytes = "1" base64 = "0.22" -imagesize = "0.14" rustls = "0.23" tokio-rustls = "0.26" diff --git a/channels/irc/src/main.rs b/channels/irc/src/main.rs index e81c4fe..4b20284 100644 --- a/channels/irc/src/main.rs +++ b/channels/irc/src/main.rs @@ -237,19 +237,11 @@ impl State { async fn send_privmsg(&mut self, target: &str, msg: &str) -> io::Result<()> { // Send PRIVMSG, which is used for both private and channel messages. // Splits into multiple fragments if necessary. - // - // Two constraints: - // 1. IRC max line = 512 bytes including CRLF. The server prepends - // our prefix when relaying: ":nick!~user@host PRIVMSG target :msg\r\n" - // So per-PRIVMSG message content must fit in 512 - overhead. - // 2. Embedded '\n' in the message would be interpreted by the - // server as an end-of-command marker, truncating us. Split - // on newlines first and send each line as its own PRIVMSG. - // + // IRC max line = 512 bytes including CRLF. The server prepends + // our prefix when relaying: ":nick!~user@host PRIVMSG target :msg\r\n" // User is often ~nick (nick_len + 1). Host is up to 63 bytes. - // Cloaked OFTC hosts can be longer - pad the budget. let nick_len = self.config.nick.len(); - let overhead = 1 + nick_len + 1 + (nick_len + 1) + 1 + 80 + let overhead = 1 + nick_len + 2 + nick_len + 1 + 63 + " PRIVMSG ".len() + target.len() + " :".len() + 2; let max_msg = 512_usize.saturating_sub(overhead); @@ -257,34 +249,24 @@ impl State { return Err(io::Error::new(io::ErrorKind::InvalidInput, "target too long")); } - for line in msg.split('\n') { - let mut remaining = line; - // Empty lines (blank paragraph breaks) can't be sent as empty - // PRIVMSGs - most IRC servers reject them. Skip. - if remaining.is_empty() { continue; } - loop { - let split_at = if remaining.len() <= max_msg { - remaining.len() - } else { - // Find last char boundary at or before max_msg. - let mut i = max_msg; - while i > 0 && !remaining.is_char_boundary(i) { i -= 1; } - // Prefer splitting at a word boundary - look back up to - // max_msg/4 chars for a space. With dense content (code) - // we may not find one; fall back to the char boundary. - let lookback = max_msg / 4; - let bytes = remaining.as_bytes(); - let mut j = i; - while j > 0 && (i - j) < lookback && bytes[j - 1] != b' ' { - j -= 1; - } - if j > 0 && bytes[j - 1] == b' ' { j } else { i } - }; - let (chunk, rest) = remaining.split_at(split_at); - self.send_raw(&format!("PRIVMSG {target} :{chunk}")).await?; - remaining = rest; - if remaining.is_empty() { break; } - } + // Split on UTF-8 char boundaries + let mut remaining = msg; + while !remaining.is_empty() { + let split_at = if remaining.len() <= max_msg { + remaining.len() + } else { + // Find last char boundary at or before max_msg + let mut i = max_msg; + 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 > 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 } + }; + let (chunk, rest) = remaining.split_at(split_at); + self.send_raw(&format!("PRIVMSG {target} :{chunk}")).await?; + remaining = rest; } Ok(()) } diff --git a/src/agent/api/mod.rs b/src/agent/api/mod.rs index be5e58e..7c06fa7 100644 --- a/src/agent/api/mod.rs +++ b/src/agent/api/mod.rs @@ -22,21 +22,6 @@ 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, - pub layers: Vec, -} - -/// 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>; - /// A JoinHandle that aborts its task when dropped. pub(crate) struct AbortOnDrop(tokio::task::JoinHandle<()>); @@ -60,10 +45,7 @@ pub(crate) struct SamplingParams { /// One token from the streaming completions API. pub enum StreamToken { - /// 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 }, + Token(u32), Done { usage: Option }, Error(String), } @@ -91,10 +73,9 @@ impl ApiClient { } } - pub(crate) fn stream_completion_mm( + pub(crate) fn stream_completion( &self, prompt_tokens: &[u32], - images: &[super::context::WireImage], sampling: SamplingParams, priority: Option, ) -> (mpsc::UnboundedReceiver, AbortOnDrop) { @@ -103,15 +84,12 @@ impl ApiClient { let api_key = self.api_key.clone(); let model = self.model.clone(); let prompt_tokens = prompt_tokens.to_vec(); - let images: Vec<(Vec, String)> = images.iter() - .map(|i| (i.bytes.clone(), i.mime.clone())) - .collect(); let base_url = self.base_url.clone(); let handle = tokio::spawn(async move { let result = stream_completions( &client, &base_url, &api_key, &model, - &prompt_tokens, &images, &tx, sampling, priority, + &prompt_tokens, &tx, sampling, priority, ).await; if let Err(e) = result { let _ = tx.send(StreamToken::Error(e.to_string())); @@ -124,32 +102,6 @@ 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> { - 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( @@ -158,7 +110,6 @@ async fn stream_completions( api_key: &str, model: &str, prompt_tokens: &[u32], - images: &[(Vec, String)], tx: &mpsc::UnboundedSender, sampling: SamplingParams, priority: Option, @@ -175,14 +126,6 @@ async fn stream_completions( "skip_special_tokens": false, "stop_token_ids": [super::tokenizer::IM_END], }); - if !images.is_empty() { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD; - let uris: Vec = images.iter() - .map(|(bytes, mime)| format!("data:{};base64,{}", mime, b64.encode(bytes))) - .collect(); - request["multi_modal_data"] = serde_json::json!({ "image": uris }); - } if let Some(p) = priority { request["priority"] = serde_json::json!(p); } @@ -216,45 +159,17 @@ 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> = 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::>() - }) - }).collect::>>() - }) - }).collect() - }); - if let Some(ids) = choice["token_ids"].as_array() { - for (i, id_val) in ids.iter().enumerate() { + for id_val in ids { if let Some(id) = id_val.as_u64() { - let readout = readouts - .as_ref() - .and_then(|r| r.get(i).cloned()); - let _ = tx.send(StreamToken::Token { - id: id as u32, - readout, - }); + let _ = tx.send(StreamToken::Token(id as u32)); } } } else if let Some(text) = choice["text"].as_str() { - // 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. + // Fallback: provider didn't return token_ids, encode locally if !text.is_empty() { for id in super::tokenizer::encode(text) { - let _ = tx.send(StreamToken::Token { id, readout: None }); + let _ = tx.send(StreamToken::Token(id)); } } } diff --git a/src/agent/context.rs b/src/agent/context.rs index 2009cfc..c43c023 100644 --- a/src/agent/context.rs +++ b/src/agent/context.rs @@ -81,33 +81,10 @@ pub enum NodeBody { Memory { key: String, text: String, score: Option }, Dmn(String), - // Vision input — rendered as <|vision_start|> <|image_pad|>×N <|vision_end|>. - // `token_count` is N, the count vLLM will compute for this image's grid. - Image { - #[serde(with = "b64_bytes")] - bytes: Vec, - mime: String, - orig_height: u32, - orig_width: u32, - token_count: u32, - }, - // Non-visible (0 tokens in prompt) Log(String), } -mod b64_bytes { - use base64::{Engine, engine::general_purpose::STANDARD}; - use serde::{Serializer, Deserializer, Deserialize}; - pub fn serialize(bytes: &[u8], s: S) -> Result { - s.serialize_str(&STANDARD.encode(bytes)) - } - pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { - let s = String::deserialize(d)?; - STANDARD.decode(s).map_err(serde::de::Error::custom) - } -} - /// A leaf node: typed content with cached token IDs. /// Token IDs are not serialized — they're recomputed on deserialization. #[derive(Debug, Clone, Serialize)] @@ -115,7 +92,7 @@ pub struct NodeLeaf { body: NodeBody, #[serde(skip)] token_ids: Vec, - timestamp: DateTime, + timestamp: Option>, } impl<'de> Deserialize<'de> for NodeLeaf { @@ -123,10 +100,14 @@ impl<'de> Deserialize<'de> for NodeLeaf { #[derive(Deserialize)] struct Raw { body: NodeBody, - timestamp: DateTime, + timestamp: Option>, } let raw = Raw::deserialize(deserializer)?; - let token_ids = raw.body.compute_token_ids(); + let token_ids = if raw.body.is_prompt_visible() { + tokenizer::encode(&raw.body.render()) + } else { + vec![] + }; Ok(NodeLeaf { body: raw.body, token_ids, timestamp: raw.timestamp }) } } @@ -138,7 +119,6 @@ pub enum AstNode { Branch { role: Role, children: Vec, - timestamp: DateTime, /// Per-response memory attribution from full scoring matrix. /// Maps memory key → divergence score for this response. #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] @@ -218,11 +198,7 @@ impl NodeBody { fn render_into(&self, out: &mut String) { match self { Self::Content(text) => out.push_str(text), - Self::Thinking(text) => { - out.push_str("\n"); - out.push_str(text); - out.push_str("\n\n"); - } + Self::Thinking(_) => {}, Self::Log(_) => {}, Self::ToolCall { name, arguments } => { out.push_str("\n"); @@ -244,13 +220,6 @@ impl NodeBody { out.push_str(text); out.push_str("<|im_end|>\n"); } - Self::Image { token_count, .. } => { - out.push_str("<|vision_start|>"); - for _ in 0..*token_count { - out.push_str("<|image_pad|>"); - } - out.push_str("<|vision_end|>"); - } } } @@ -262,27 +231,7 @@ impl NodeBody { } fn is_prompt_visible(&self) -> bool { - !matches!(self, Self::Log(_)) - } - - /// Hand-assemble token IDs for body types where running the tokenizer - /// on the rendered text would be needlessly expensive (Image). Falls - /// back to encoding the rendered text for everything else. - fn compute_token_ids(&self) -> Vec { - if !self.is_prompt_visible() { - return Vec::new(); - } - match self { - Self::Image { token_count, .. } => { - let mut ids = Vec::with_capacity(*token_count as usize + 2); - ids.push(tokenizer::VISION_START); - ids.extend(std::iter::repeat(tokenizer::IMAGE_PAD) - .take(*token_count as usize)); - ids.push(tokenizer::VISION_END); - ids - } - _ => tokenizer::encode(&self.render()), - } + !matches!(self, Self::Thinking(_) | Self::Log(_)) } /// The text content of this leaf (for display, not rendering). @@ -292,26 +241,29 @@ impl NodeBody { | Self::ToolResult(t) | Self::Dmn(t) => t, Self::ToolCall { name, .. } => name, Self::Memory { text, .. } => text, - Self::Image { mime, .. } => mime, } } } impl NodeLeaf { fn new(body: NodeBody) -> Self { - let token_ids = body.compute_token_ids(); - Self { body, token_ids, timestamp: Utc::now() } + let token_ids = if body.is_prompt_visible() { + tokenizer::encode(&body.render()) + } else { + vec![] + }; + Self { body, token_ids, timestamp: None } } pub fn with_timestamp(mut self, ts: DateTime) -> Self { - self.timestamp = ts; + self.timestamp = Some(ts); self } pub fn body(&self) -> &NodeBody { &self.body } pub fn token_ids(&self) -> &[u32] { &self.token_ids } pub fn tokens(&self) -> usize { self.token_ids.len() } - pub fn timestamp(&self) -> DateTime { self.timestamp } + pub fn timestamp(&self) -> Option> { self.timestamp } } impl AstNode { @@ -352,35 +304,16 @@ impl AstNode { Self::Leaf(NodeLeaf::new(NodeBody::Log(text.into()))) } - /// Build an Image leaf. `token_count` is computed from the image - /// dimensions using Qwen3-VL's resizing rules. - pub fn image( - bytes: Vec, - mime: impl Into, - orig_height: u32, - orig_width: u32, - ) -> Self { - let token_count = qwen3_image_token_count(orig_height, orig_width); - Self::Leaf(NodeLeaf::new(NodeBody::Image { - bytes, - mime: mime.into(), - orig_height, - orig_width, - token_count, - })) - } - // -- Branch constructors -------------------------------------------------- pub fn branch(role: Role, children: Vec) -> Self { - Self::Branch { role, children, timestamp: Utc::now(), memory_scores: Default::default() } + Self::Branch { role, children, memory_scores: Default::default() } } pub fn system_msg(text: impl Into) -> Self { Self::Branch { role: Role::System, children: vec![Self::content(text)], - timestamp: Utc::now(), memory_scores: Default::default(), } } @@ -389,7 +322,6 @@ impl AstNode { Self::Branch { role: Role::User, children: vec![Self::content(text)], - timestamp: Utc::now(), memory_scores: Default::default(), } } @@ -399,13 +331,16 @@ impl AstNode { pub fn retokenize(self) -> Self { match self { Self::Leaf(leaf) => { - let token_ids = leaf.body.compute_token_ids(); + let token_ids = if leaf.body.is_prompt_visible() { + tokenizer::encode(&leaf.body.render()) + } else { + vec![] + }; Self::Leaf(NodeLeaf { token_ids, ..leaf }) } - Self::Branch { role, children, timestamp, memory_scores } => Self::Branch { + Self::Branch { role, children, memory_scores, .. } => Self::Branch { role, children: children.into_iter().map(|c| c.retokenize()).collect(), - timestamp, memory_scores, }, } @@ -413,8 +348,8 @@ impl AstNode { pub fn with_timestamp(mut self, ts: DateTime) -> Self { match &mut self { - Self::Leaf(leaf) => leaf.timestamp = ts, - Self::Branch { timestamp, .. } => *timestamp = ts, + Self::Leaf(leaf) => leaf.timestamp = Some(ts), + Self::Branch { .. } => {} } self } @@ -435,7 +370,7 @@ impl AstNode { /// Short label for the UI. pub fn label(&self) -> String { - let app = crate::config::app(); + let cfg = crate::config::get(); match self { Self::Branch { role, children, .. } => { let preview = children.first() @@ -444,8 +379,8 @@ impl AstNode { .unwrap_or_default(); match role { Role::System => "system".into(), - Role::User => format!("{}: {}", app.user_name, preview), - Role::Assistant => format!("{}: {}", app.assistant_name, preview), + Role::User => format!("{}: {}", cfg.user_name, preview), + Role::Assistant => format!("{}: {}", cfg.assistant_name, preview), } } Self::Leaf(leaf) => match &leaf.body { @@ -458,8 +393,6 @@ impl AstNode { None => format!("mem: {}", key), }, NodeBody::Dmn(_) => "dmn".into(), - NodeBody::Image { orig_height, orig_width, token_count, .. } => - format!("image: {}x{} ({} tokens)", orig_width, orig_height, token_count), NodeBody::Log(t) => format!("log: {}", truncate_preview(t, 60)), }, } @@ -652,17 +585,13 @@ fn drain_safe(buf: &mut String, tag_len: usize) -> String { } impl ResponseParser { - /// @in_think: whether the model's output begins inside a block. - /// Set when the prompt was prefilled with "\n" (native thinking - /// mode) so the parser captures reasoning tokens as Thinking until the - /// model emits . - pub fn new(branch_idx: usize, in_think: bool) -> Self { + pub fn new(branch_idx: usize) -> Self { Self { branch_idx, call_counter: 0, buf: String::new(), content_parts: Vec::new(), - in_think, + in_think: false, think_buf: String::new(), in_tool_call: false, tool_call_buf: String::new(), @@ -690,12 +619,7 @@ impl ResponseParser { let mut full_text = String::new(); while let Some(event) = stream.recv().await { match event { - super::api::StreamToken::Token { id, readout } => { - if let Some(r) = readout { - if let Ok(mut buf) = agent.readout.lock() { - buf.push(id, r); - } - } + super::api::StreamToken::Token(id) => { let text = super::tokenizer::decode(&[id]); full_text.push_str(&text); let mut ctx = agent.context.lock().await; @@ -897,153 +821,6 @@ impl Ast for ContextState { } } -/// An image collected from the AST for a request body. The AST stores -/// the pre-expanded token form (N image_pads) for accurate budget -/// accounting; the wire form collapses each Image to a single -/// `<|image_pad|>` between vision bookends and ships the bytes -/// separately as multi_modal_data. -pub struct WireImage { - pub bytes: Vec, - pub mime: String, -} - -fn wire_into(node: &AstNode, tokens: &mut Vec, images: &mut Vec) { - match node { - AstNode::Leaf(leaf) => match leaf.body() { - NodeBody::Image { bytes, mime, .. } => { - tokens.push(tokenizer::VISION_START); - tokens.push(tokenizer::IMAGE_PAD); - tokens.push(tokenizer::VISION_END); - images.push(WireImage { - bytes: bytes.clone(), - mime: mime.clone(), - }); - } - _ => tokens.extend_from_slice(leaf.token_ids()), - }, - AstNode::Branch { role, children, .. } => { - tokens.push(tokenizer::IM_START); - tokens.extend(tokenizer::encode(&format!("{}\n", role.as_str()))); - for c in children { - wire_into(c, tokens, images); - } - tokens.push(tokenizer::IM_END); - tokens.extend(tokenizer::encode("\n")); - } - } -} - -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::>() - .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, 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( - &self, - conv_range: std::ops::Range, - mut skip: F, - ) -> (Vec, Vec, Vec<(usize, usize)>) - where F: FnMut(&AstNode) -> bool, - { - let mut tokens = Vec::new(); - let mut images = Vec::new(); - 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); - } - 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) - } -} - impl ContextState { fn section_mut(&mut self, section: Section) -> &mut Vec { match section { @@ -1076,7 +853,11 @@ impl ContextState { let node = &mut nodes[index]; match node { AstNode::Leaf(leaf) => { - let token_ids = body.compute_token_ids(); + let token_ids = if body.is_prompt_visible() { + tokenizer::encode(&body.render()) + } else { + vec![] + }; leaf.body = body; leaf.token_ids = token_ids; } @@ -1104,16 +885,6 @@ impl ContextState { self.section_mut(section).clear(); } - /// Total tokens across every section that gets serialized into the prompt. - /// Cheap sum over cached `node.tokens()`; call this before assembling to - /// decide whether to trim. - pub fn total_tokens(&self) -> usize { - self.system().iter().map(|n| n.tokens()).sum::() - + self.identity().iter().map(|n| n.tokens()).sum::() - + self.journal().iter().map(|n| n.tokens()).sum::() - + self.conversation().iter().map(|n| n.tokens()).sum::() - } - /// Dedup and trim conversation entries to fit within the context budget. /// /// Phase 1: Drop duplicate memories (keep last) and DMN entries. @@ -1216,63 +987,8 @@ impl ContextState { } } -// --------------------------------------------------------------------------- -// Qwen3-VL image token count -// -// Port of Qwen2VLImageProcessor.smart_resize + image_token_count. We need the -// exact same answer that vLLM's Qwen3VL processor will produce, because the -// token stream in our context must match what vLLM expands `<|image_pad|>` -// to at request time. Constants come from Qwen3.5-27B's preprocessor_config. -// --------------------------------------------------------------------------- - -const QWEN3_PATCH_SIZE: u32 = 16; -const QWEN3_MERGE_SIZE: u32 = 2; -const QWEN3_MIN_PIXELS: u64 = 65_536; -const QWEN3_MAX_PIXELS: u64 = 16_777_216; - -fn smart_resize(h: u32, w: u32, factor: u32, min_pixels: u64, max_pixels: u64) -> (u32, u32) { - let max_s = h.max(w) as f64; - let min_s = h.min(w) as f64; - assert!(max_s / min_s <= 200.0, "aspect ratio too extreme: {}x{}", h, w); - - let fh = h as f64; - let fw = w as f64; - let ff = factor as f64; - - let h_bar = ((fh / ff).round() as u32) * factor; - let w_bar = ((fw / ff).round() as u32) * factor; - let total = (h_bar as u64) * (w_bar as u64); - - if total > max_pixels { - let beta = ((fh * fw) / max_pixels as f64).sqrt(); - let hf = ((fh / beta / ff).floor() as u32) * factor; - let wf = ((fw / beta / ff).floor() as u32) * factor; - (hf.max(factor), wf.max(factor)) - } else if total < min_pixels { - let beta = (min_pixels as f64 / (fh * fw)).sqrt(); - let hc = ((fh * beta / ff).ceil() as u32) * factor; - let wc = ((fw * beta / ff).ceil() as u32) * factor; - (hc, wc) - } else { - (h_bar, w_bar) - } -} - -/// Compute how many `<|image_pad|>` tokens vLLM will emit for an image of -/// the given dimensions. Matches Qwen3VL's feature-size calculation exactly: -/// (grid_h * grid_w) / merge_size^2 -/// where (grid_h, grid_w) = resized dims / patch_size. -fn qwen3_image_token_count(orig_h: u32, orig_w: u32) -> u32 { - let factor = QWEN3_PATCH_SIZE * QWEN3_MERGE_SIZE; - let (rh, rw) = smart_resize(orig_h, orig_w, factor, QWEN3_MIN_PIXELS, QWEN3_MAX_PIXELS); - (rh / QWEN3_PATCH_SIZE) * (rw / QWEN3_PATCH_SIZE) / (QWEN3_MERGE_SIZE * QWEN3_MERGE_SIZE) -} - pub fn context_window() -> usize { - let app = crate::config::app(); - app.backends.get(&app.default_backend) - .and_then(|b| b.context_window) - .unwrap_or(128_000) + crate::config::get().api_context_window } pub fn context_budget_tokens() -> usize { @@ -1377,7 +1093,7 @@ mod tests { fn parse_into_ctx(chunks: &[&str]) -> (ContextState, Vec) { let mut ctx = ContextState::new(); ctx.push_no_log(Section::Conversation, AstNode::branch(Role::Assistant, vec![])); - let mut p = ResponseParser::new(0, false); + let mut p = ResponseParser::new(0); let mut calls = Vec::new(); for chunk in chunks { // Feed each chunk as a single token (id=0 for tests) @@ -1441,7 +1157,7 @@ mod tests { let text = "thoughtresponse"; let mut ctx = ContextState::new(); ctx.push_no_log(Section::Conversation, AstNode::branch(Role::Assistant, vec![])); - let mut p = ResponseParser::new(0, false); + let mut p = ResponseParser::new(0); for ch in text.chars() { p.feed_token(&ch.to_string(), &mut ctx); } @@ -1457,7 +1173,7 @@ mod tests { let text = "text\n\nls\n\nmore"; let mut ctx = ContextState::new(); ctx.push_no_log(Section::Conversation, AstNode::branch(Role::Assistant, vec![])); - let mut p = ResponseParser::new(0, false); + let mut p = ResponseParser::new(0); let mut tool_calls = 0; for ch in text.chars() { tool_calls += p.feed_token(&ch.to_string(), &mut ctx).len(); @@ -1505,10 +1221,8 @@ mod tests { AstNode::thinking("hmm"), AstNode::content("answer"), ]); - // Thinking renders wrapped in ... so the model sees - // previous turns' reasoning (Qwen 3.6 style: CoT stays in the - // conversation across turns). - assert_eq!(node.render(), "<|im_start|>assistant\n\nhmm\n\nanswer<|im_end|>\n"); + // Thinking renders as empty, content renders as-is + assert_eq!(node.render(), "<|im_start|>assistant\nanswer<|im_end|>\n"); } #[test] @@ -1587,19 +1301,10 @@ mod tests { fn test_tokenize_invisible_nodes_are_zero() { if !init_tokenizer() { return; } + assert_eq!(AstNode::thinking("deep thoughts").tokens(), 0); assert_eq!(AstNode::log("debug info").tokens(), 0); } - #[test] - fn test_tokenize_thinking_matches_rendered_tags() { - if !init_tokenizer() { return; } - - // Thinking is now prompt-visible (wrapped in ...); - // token count must match the rendered wrapping. - let node = AstNode::thinking("deep thoughts"); - assert_eq!(node.tokens(), tokenizer::encode(&node.render()).len()); - } - #[test] fn test_tokenize_decode_roundtrip() { if !init_tokenizer() { return; } @@ -1635,139 +1340,4 @@ mod tests { assert_token_invariants(node); assert!(node.tokens() > 0); } - - // -- Timestamp deserialization tests ------------------------------------------ - - #[test] - fn test_timestamp_null_rejected() { - // Missing/null timestamps used to be accepted via a lenient - // deserialize fallback. Post-migration the schema is strict. - let json = r#"{"Leaf":{"body":{"Content":"hello"},"timestamp":null}}"#; - assert!(serde_json::from_str::(json).is_err()); - } - - #[test] - fn test_timestamp_missing_rejected() { - let json = r#"{"Leaf":{"body":{"Content":"hello"}}}"#; - assert!(serde_json::from_str::(json).is_err()); - } - - #[test] - fn test_branch_timestamp_missing_rejected() { - let json = r#"{"Branch":{"role":"User","children":[]}}"#; - assert!(serde_json::from_str::(json).is_err()); - } - - // -- Image leaf tests --------------------------------------------------------- - - #[test] - fn test_smart_resize_within_bounds() { - // Typical case: 1024x768 → rounded to multiples of 32, under max. - let (h, w) = smart_resize(768, 1024, 32, 65_536, 16_777_216); - assert_eq!(h, 768); - assert_eq!(w, 1024); - } - - #[test] - fn test_smart_resize_upscales_tiny() { - // 32x32 = 1024 pixels, below min_pixels=65536. Should scale up. - let (h, w) = smart_resize(32, 32, 32, 65_536, 16_777_216); - assert!((h as u64) * (w as u64) >= 65_536, - "resized {}x{} is under min_pixels", h, w); - assert_eq!(h % 32, 0); - assert_eq!(w % 32, 0); - } - - #[test] - fn test_smart_resize_downscales_huge() { - // 8000x6000 = 48M pixels, above max_pixels=16M. Should scale down. - let (h, w) = smart_resize(8000, 6000, 32, 65_536, 16_777_216); - assert!((h as u64) * (w as u64) <= 16_777_216, - "resized {}x{} exceeds max_pixels", h, w); - assert_eq!(h % 32, 0); - assert_eq!(w % 32, 0); - } - - #[test] - fn test_qwen3_token_count_matches_formula() { - // 512x512 → resized to 512x512 (already multiple of 32, within bounds). - // grid = 32x32, tokens = 32*32/4 = 256. - assert_eq!(qwen3_image_token_count(512, 512), 256); - } - - #[test] - fn test_image_render_and_token_ids() { - let node = AstNode::image(vec![0u8, 1, 2, 3], "image/png", 512, 512); - let leaf = node.leaf().unwrap(); - // 3 tokens of bookend + 256 image_pad tokens - assert_eq!(leaf.token_ids().len(), 258); - assert_eq!(leaf.token_ids()[0], tokenizer::VISION_START); - assert_eq!(leaf.token_ids()[257], tokenizer::VISION_END); - for pad in &leaf.token_ids()[1..257] { - assert_eq!(*pad, tokenizer::IMAGE_PAD); - } - // Rendered text has the expected bookends. - let rendered = leaf.body().render(); - assert!(rendered.starts_with("<|vision_start|>")); - assert!(rendered.ends_with("<|vision_end|>")); - } - - #[test] - fn test_wire_prompt_collapses_image_pads() { - let mut ctx = ContextState::new(); - ctx.push_no_log(Section::Conversation, AstNode::branch(Role::User, vec![ - AstNode::content("look:"), - AstNode::image(vec![0xDE, 0xAD], "image/png", 512, 512), - ])); - - // AST side: N image_pads + bookends, full budget accounting. - let full = ctx.token_ids(); - let n_image_pads_full = full.iter() - .filter(|&&t| t == tokenizer::IMAGE_PAD).count(); - 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(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); - assert_eq!(images.len(), 1); - assert_eq!(images[0].bytes, vec![0xDE, 0xAD]); - assert_eq!(images[0].mime, "image/png"); - - // vision_start/vision_end bookends are preserved in wire form. - assert_eq!(wire.iter().filter(|&&t| t == tokenizer::VISION_START).count(), 1); - assert_eq!(wire.iter().filter(|&&t| t == tokenizer::VISION_END).count(), 1); - } - - #[test] - fn test_image_serde_roundtrip() { - let node = AstNode::image(vec![0xDE, 0xAD, 0xBE, 0xEF], "image/png", 64, 64); - let json = serde_json::to_string(&node).unwrap(); - // bytes must be base64-encoded in the JSON form - assert!(json.contains("3q2+7w==")); - let back: AstNode = serde_json::from_str(&json).unwrap(); - let leaf = back.leaf().unwrap(); - match leaf.body() { - NodeBody::Image { bytes, mime, orig_height, orig_width, token_count } => { - assert_eq!(bytes, &[0xDE, 0xAD, 0xBE, 0xEF]); - assert_eq!(mime, "image/png"); - assert_eq!(*orig_height, 64); - assert_eq!(*orig_width, 64); - assert_eq!(*token_count, qwen3_image_token_count(64, 64)); - } - other => panic!("expected Image, got {:?}", other), - } - // token_ids are recomputed on deserialization - assert_eq!(leaf.token_ids().len(), leaf.tokens()); - } - - #[test] - fn test_timestamp_present_accepted() { - let json = r#"{"Leaf":{"body":{"Content":"hi"},"timestamp":"2026-04-16T12:00:00Z"}}"#; - let node: AstNode = serde_json::from_str(json).unwrap(); - let leaf = node.leaf().unwrap(); - assert_eq!(leaf.timestamp().to_rfc3339(), - "2026-04-16T12:00:00+00:00"); - } } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 2c3a98a..db1bf39 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -16,7 +16,6 @@ pub mod api; pub mod context; pub mod oneshot; -pub mod readout; pub mod tokenizer; pub mod tools; @@ -140,14 +139,10 @@ impl DispatchState { pub struct Agent { pub client: ApiClient, pub app_config: crate::config::AppConfig, + pub prompt_file: String, pub session_id: String, pub context: crate::Mutex, pub state: crate::Mutex, - /// 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. @@ -178,10 +173,14 @@ 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. pub priority: Option, + /// Forked agents should not compact on overflow — it blows the + /// KV cache prefix and evicts the step prompts. + pub no_compact: bool, pub changed: Arc, } @@ -190,6 +189,7 @@ impl Agent { client: ApiClient, personality: Vec<(String, String)>, app_config: crate::config::AppConfig, + prompt_file: String, conversation_log: Option, active_tools: tools::ActiveTools, agent_tools: Vec, @@ -217,13 +217,12 @@ 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, + prompt_file, session_id, context: crate::Mutex::new(context), - readout, state: crate::Mutex::new(AgentState { tools: agent_tools, mcp_tools: McpToolAccess::All, @@ -241,39 +240,15 @@ 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, changed: Arc::new(tokio::sync::Notify::new()), }), }); 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 } @@ -284,14 +259,9 @@ impl Agent { Arc::new(Self { client: self.client.clone(), app_config: self.app_config.clone(), + prompt_file: self.prompt_file.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, @@ -309,42 +279,26 @@ 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, changed: Arc::new(tokio::sync::Notify::new()), }), }) } pub async fn assemble_prompt_tokens(&self) -> Vec { - self.assemble_prompt().await.0 - } - - /// Assemble a ready-to-send prompt: token stream in wire form (each - /// image collapsed to a single `<|image_pad|>`) paired with the - /// images to attach as multi_modal_data. - /// - /// Pre-send size check: if the context has grown past budget since the - /// last compact (accumulation between turns, a fork's context getting - /// bigger than expected, etc.), trim here rather than letting vLLM - /// reject the request. Client-side tokenization means we already know - /// the exact token count so there's no reason to round-trip an - /// oversize request. - pub async fn assemble_prompt(&self) -> (Vec, Vec) { - let mut ctx = self.context.lock().await; - if ctx.total_tokens() > context::context_budget_tokens() { - ctx.trim_conversation(); - } + let ctx = self.context.lock().await; let st = self.state.lock().await; - let (mut tokens, images, _) = - ctx.wire_prompt(0..ctx.conversation().len(), |_| false); + let mut tokens = ctx.token_ids(); tokens.push(tokenizer::IM_START); if st.think_native { tokens.extend(tokenizer::encode("assistant\n\n")); } else { tokens.extend(tokenizer::encode("assistant\n")); } - (tokens, images) + tokens } /// Rebuild the tools section of the system prompt from the current tools list. @@ -404,11 +358,10 @@ impl Agent { let _thinking = start_activity(&agent, "thinking...").await; let (rx, _stream_guard) = { - let (prompt_tokens, images) = agent.assemble_prompt().await; + let prompt_tokens = agent.assemble_prompt_tokens().await; let st = agent.state.lock().await; - agent.client.stream_completion_mm( + agent.client.stream_completion( &prompt_tokens, - &images, api::SamplingParams { temperature: st.temperature, top_p: st.top_p, @@ -456,16 +409,21 @@ impl Agent { // Check for stream/parse errors match parser_handle.await { Ok(Err(e)) => { - if context::is_context_overflow(&e) && overflow_retries < 2 { - overflow_retries += 1; - let msg = format!("context overflow — compacting ({}/2)", overflow_retries); - match &overflow_activity { - Some(a) => a.update(&msg).await, - None => overflow_activity = Some( - start_activity(&agent, &msg).await), + if context::is_context_overflow(&e) { + if agent.state.lock().await.no_compact { + return Err(e); + } + if overflow_retries < 2 { + overflow_retries += 1; + let msg = format!("context overflow — compacting ({}/2)", overflow_retries); + match &overflow_activity { + Some(a) => a.update(&msg).await, + None => overflow_activity = Some( + start_activity(&agent, &msg).await), + } + agent.compact().await; + continue; } - agent.compact().await; - continue; } return Err(e); } @@ -621,9 +579,20 @@ impl Agent { } pub async fn compact(&self) { - // Identity section is left in place — mid-session rebuilds discard - // memory scores. Content edits to personality nodes get picked up at - // the next restart via new() + restore_from_log(). + match crate::config::reload_context().await { + Ok(personality) => { + let mut ctx = self.context.lock().await; + // System section (prompt + tools) set by new(), don't touch it + ctx.clear(Section::Identity); + for (name, content) in &personality { + ctx.push_no_log(Section::Identity, AstNode::memory(name, content)); + } + } + Err(e) => { + dbglog!("warning: failed to reload identity: {:#}", e); + } + } + self.load_startup_journal().await; self.context.lock().await.trim_conversation(); diff --git a/src/agent/oneshot.rs b/src/agent/oneshot.rs index 8bc8b53..2fce906 100644 --- a/src/agent/oneshot.rs +++ b/src/agent/oneshot.rs @@ -183,8 +183,8 @@ fn resolve_prompt( state: &std::collections::BTreeMap, recently_written: &[String], ) -> String { - let template = template.replace("{assistant_name}", - &crate::config::app().assistant_name); + let cfg = crate::config::get(); + let template = template.replace("{assistant_name}", &cfg.assistant_name); let mut result = String::with_capacity(template.len()); let mut rest = template.as_str(); while let Some(start) = rest.find("{{") { @@ -247,20 +247,25 @@ impl AutoAgent { &mut self, bail_fn: Option<&(dyn Fn(usize) -> Result<(), String> + Sync)>, ) -> Result<(), String> { - // Load system prompt + identity from config. + let config = crate::config::get(); + let base_url = config.api_base_url.as_deref().unwrap_or(""); + let api_key = config.api_key.as_deref().unwrap_or(""); + let model = config.api_model.as_deref().unwrap_or(""); + if base_url.is_empty() || model.is_empty() { + return Err("API not configured (no base_url or model)".to_string()); + } + let client = super::api::ApiClient::new(base_url, api_key, model); + + // Load system prompt + identity from config let cli = crate::user::CliArgs::default(); let (app, _) = crate::config::load_app(&cli) .map_err(|e| format!("config: {}", e))?; - let resolved = app.resolve_model(&app.default_backend) - .map_err(|e| format!("API not configured: {}", e))?; - let client = super::api::ApiClient::new( - &resolved.api_base, &resolved.api_key, &resolved.model_id); let personality = crate::config::reload_context() .await.map_err(|e| format!("config: {}", e))?; let agent = Agent::new( client, personality, - app, + app, String::new(), None, super::tools::ActiveTools::new(), super::tools::tools(), @@ -492,20 +497,15 @@ pub async fn run_one_agent( .map(|s| s.phase.clone()).collect(); // Bail check: if the agent defines a bail script, run it between steps. - // The script also refreshes our pid-file with the current phase — that's - // how concurrent agents know which phase each of us is in. let bail_script = def.bail.as_ref().map(|name| defs::agents_dir().join(name)); let state_dir_for_bail = state_dir.clone(); + // Find our own pid file so we can pass it to the bail script let our_pid = std::process::id(); let our_pid_file = format!("pid-{}", our_pid); - let step_phases_for_bail = step_phases.clone(); let bail_fn = move |step_idx: usize| -> Result<(), String> { if let Some(ref script) = bail_script { - let phase = step_phases_for_bail.get(step_idx) - .map(String::as_str).unwrap_or(""); let status = std::process::Command::new(script) .arg(&our_pid_file) - .arg(phase) .current_dir(&state_dir_for_bail) .status() .map_err(|e| format!("bail script {:?} failed: {}", script, e))?; diff --git a/src/agent/readout.rs b/src/agent/readout.rs deleted file mode 100644 index da843b6..0000000 --- a/src/agent/readout.rs +++ /dev/null @@ -1,75 +0,0 @@ -// 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 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, - pub recent: VecDeque, - 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) { - 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>; - -pub fn new_shared() -> SharedReadoutBuffer { - Arc::new(Mutex::new(ReadoutBuffer::new())) -} diff --git a/src/agent/tokenizer.rs b/src/agent/tokenizer.rs index cd0acaf..85ac823 100644 --- a/src/agent/tokenizer.rs +++ b/src/agent/tokenizer.rs @@ -16,9 +16,6 @@ static TOKENIZER: OnceLock = OnceLock::new(); /// Special token IDs for Qwen 3.5 pub const IM_START: u32 = 248045; pub const IM_END: u32 = 248046; -pub const VISION_START: u32 = 248053; -pub const VISION_END: u32 = 248054; -pub const IMAGE_PAD: u32 = 248056; /// Initialize the global tokenizer from a file path. /// Call once at startup. Panics if the file can't be loaded. diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs index 8904fc3..f72b015 100644 --- a/src/agent/tools/mod.rs +++ b/src/agent/tools/mod.rs @@ -242,7 +242,13 @@ pub fn summarize_args(tool_name: &str, args: &serde_json::Value) -> String { .as_str() .unwrap_or("") .to_string(), - "view_image" => args["file_path"].as_str().unwrap_or("").to_string(), + "view_image" => { + if let Some(pane) = args["pane_id"].as_str() { + format!("pane {}", pane) + } else { + args["file_path"].as_str().unwrap_or("").to_string() + } + } "journal" => { let entry = args["entry"].as_str().unwrap_or(""); if entry.len() > 60 { diff --git a/src/agent/tools/vision.rs b/src/agent/tools/vision.rs index 0e36888..83559f6 100644 --- a/src/agent/tools/vision.rs +++ b/src/agent/tools/vision.rs @@ -1,71 +1,96 @@ +use std::sync::Arc; // tools/vision.rs — Image viewing tool // -// Reads an image file from disk, decodes its dimensions, and injects it -// into the context as a user-role message containing a NodeBody::Image -// leaf. The leaf carries raw bytes; the API layer extracts them into -// multi_modal_data when building vLLM requests. - -use std::sync::Arc; +// Reads image files from disk and returns them as base64 data URIs +// for multimodal models. Also supports capturing tmux pane contents +// as screenshots. use anyhow::{Context, Result}; +use base64::Engine; use serde::Deserialize; -use crate::agent::context::{AstNode, Role, Section}; - #[derive(Deserialize)] struct Args { - file_path: String, + file_path: Option, + pane_id: Option, + #[serde(default = "default_lines")] + lines: usize, } +fn default_lines() -> usize { 50 } + pub fn tool() -> super::Tool { super::Tool { name: "view_image", - description: "View an image file. Supports PNG, JPEG, GIF, WebP, BMP. The image is inserted into the conversation and can be analyzed by the vision model.", - parameters_json: r#"{"type":"object","properties":{"file_path":{"type":"string","description":"Path to the image file"}},"required":["file_path"]}"#, - handler: Arc::new(|agent, v| Box::pin(async move { - view_image(agent, v).await - })), + description: "View an image file or capture a tmux pane screenshot. Supports PNG, JPEG, GIF, WebP. Use pane_id to capture a tmux pane instead.", + parameters_json: r#"{"type":"object","properties":{"file_path":{"type":"string","description":"Path to an image file"},"pane_id":{"type":"string","description":"Tmux pane ID to capture (e.g. '0:1.0')"},"lines":{"type":"integer","description":"Lines to capture from tmux pane (default 50)"}}}"#, + handler: Arc::new(|_a, v| Box::pin(async move { view_image_text(&v) })), } } -const MAX_SIZE: usize = 20 * 1024 * 1024; - -async fn view_image( - agent: Option>, - args: serde_json::Value, -) -> Result { - let a: Args = serde_json::from_value(args) +fn view_image_text(args: &serde_json::Value) -> anyhow::Result { + let a: Args = serde_json::from_value(args.clone()) .context("invalid view_image arguments")?; - let path = std::path::Path::new(&a.file_path); - if !path.exists() { - anyhow::bail!("file not found: {}", a.file_path); + if let Some(ref pane_id) = a.pane_id { + return capture_tmux_pane(pane_id, a.lines); } - let bytes = std::fs::read(path) - .with_context(|| format!("reading {}", a.file_path))?; + let file_path = a.file_path + .as_deref() + .context("view_image requires either file_path or pane_id")?; - if bytes.len() > MAX_SIZE { + let path = std::path::Path::new(file_path); + if !path.exists() { + anyhow::bail!("File not found: {}", file_path); + } + + let data = std::fs::read(path).with_context(|| format!("Failed to read {}", file_path))?; + + // Sanity check file size (don't send huge images) + const MAX_SIZE: usize = 20 * 1024 * 1024; // 20 MB + if data.len() > MAX_SIZE { anyhow::bail!( - "image too large: {} bytes (max {} MB)", - bytes.len(), MAX_SIZE / (1024 * 1024), + "Image too large: {} bytes (max {} MB)", + data.len(), + MAX_SIZE / (1024 * 1024) ); } - let dim = imagesize::blob_size(&bytes) - .with_context(|| format!("decoding dimensions of {}", a.file_path))?; - let (w, h) = (dim.width as u32, dim.height as u32); let mime = mime_from_extension(path); + let b64 = base64::engine::general_purpose::STANDARD.encode(&data); + let data_uri = format!("data:{};base64,{}", mime, b64); - let image_leaf = AstNode::image(bytes.clone(), mime, h, w); - let token_count = image_leaf.leaf().unwrap().tokens().saturating_sub(2); + Ok(format!("Image loaded: {} ({}, {} bytes)\n{}", file_path, mime, data.len(), data_uri)) +} - let agent = agent.context("view_image requires agent context")?; - let branch = AstNode::branch(Role::User, vec![image_leaf]); - agent.context.lock().await.push_log(Section::Conversation, branch); +/// Capture a tmux pane's text content. +fn capture_tmux_pane(pane_id: &str, lines: usize) -> Result { - Ok(format!("loaded {} ({}, {}x{}, {} tokens)", - a.file_path, mime, w, h, token_count)) + // Use tmux capture-pane to get text content, then render to image + // via a simple approach: capture text and return it (the model can + // read text directly, which is often more useful than a screenshot). + // + // For actual pixel-level screenshots we'd need a terminal renderer, + // but text capture covers 95% of use cases. + let output = std::process::Command::new("tmux") + .args(["capture-pane", "-t", pane_id, "-p", "-S", &format!("-{}", lines)]) + .output() + .context("Failed to run tmux capture-pane")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("tmux capture-pane failed: {}", stderr.trim()); + } + + let text = String::from_utf8_lossy(&output.stdout).to_string(); + + // Return as text — the model can read terminal output directly. + // This is actually more useful than a screenshot for most tasks. + Ok(format!( + "Tmux pane {} (last {} lines):\n```\n{}\n```", + pane_id, lines, text.trim_end() + )) } fn mime_from_extension(path: &std::path::Path) -> &'static str { @@ -79,7 +104,8 @@ fn mime_from_extension(path: &std::path::Path) -> &'static str { Some("jpg" | "jpeg") => "image/jpeg", Some("gif") => "image/gif", Some("webp") => "image/webp", + Some("svg") => "image/svg+xml", Some("bmp") => "image/bmp", - _ => "application/octet-stream", + _ => "image/png", // default assumption } } diff --git a/src/agent/tools/web.rs b/src/agent/tools/web.rs index 36a5b50..7ad7fc9 100644 --- a/src/agent/tools/web.rs +++ b/src/agent/tools/web.rs @@ -3,10 +3,9 @@ use std::sync::Arc; use anyhow::{Context, Result}; use serde::Deserialize; -use html2md::parse_html; -pub fn tools() -> Vec { - let mut tools = vec![ +pub fn tools() -> [super::Tool; 2] { + [ super::Tool { name: "web_fetch", description: "Fetch content from a URL and return it as text. Use for reading web pages, API responses, documentation.", @@ -15,24 +14,11 @@ pub fn tools() -> Vec { }, super::Tool { name: "web_search", - description: "Search the web via DuckDuckGo and return a list of results (title, URL, snippet). Use for finding documentation, looking up APIs, researching topics. Returns raw results you can reason over yourself.", + description: "Search the web and return results. Use for finding documentation, looking up APIs, researching topics.", parameters_json: r#"{"type":"object","properties":{"query":{"type":"string","description":"The search query"},"num_results":{"type":"integer","description":"Number of results to return (default 5)"}},"required":["query"]}"#, handler: Arc::new(|_a, v| Box::pin(async move { web_search(&v).await })), }, - ]; - // Gemini-grounded search (Google's index via Gemini's google_search tool) - // is only available if GEMINI_API_KEY is set. Returns an LLM-summarized - // answer with source URLs — use when you want a synthesized take rather - // than raw results, or as a fallback when DDG is flaky. - if std::env::var("GEMINI_API_KEY").is_ok() { - tools.push(super::Tool { - name: "gemini_search", - description: "Search Google (via Gemini's grounded-search tool) and return an LLM-summarized answer with source URLs. Prefer web_search for raw results; use this for synthesis, 'what's the consensus on X', or when DDG fails. Free-tier rate limited; don't spam it.", - parameters_json: r#"{"type":"object","properties":{"query":{"type":"string","description":"The search query"}},"required":["query"]}"#, - handler: Arc::new(|_a, v| Box::pin(async move { gemini_search(&v).await })), - }); - } - tools + ] } #[derive(Deserialize)] @@ -56,9 +42,7 @@ async fn web_fetch(args: &serde_json::Value) -> Result { let body = response.text().await .with_context(|| format!("failed to read body from {}", a.url))?; - // Convert HTML to Markdown, then truncate - let markdown = parse_html(&body); - Ok(super::truncate_output(markdown, 30000)) + Ok(super::truncate_output(body, 30000)) } // ── Search ────────────────────────────────────────────────────── @@ -127,119 +111,6 @@ async fn web_search(args: &serde_json::Value) -> Result { } } -// ── Gemini grounded search ────────────────────────────────────── - -#[derive(Deserialize)] -struct GeminiSearchArgs { - query: String, -} - -async fn gemini_search(args: &serde_json::Value) -> Result { - let a: GeminiSearchArgs = serde_json::from_value(args.clone()) - .context("invalid gemini_search arguments")?; - - let api_key = std::env::var("GEMINI_API_KEY") - .context("GEMINI_API_KEY not set")?; - - // gemini-2.0-flash has a free tier with Google search grounding. - // Request shape: `{"contents": [{"parts": [{"text": query}]}], - // "tools": [{"google_search": {}}]}`. - // Response carries the summary in candidates[0].content.parts[].text - // and grounding URLs in candidates[0].groundingMetadata.groundingChunks[].web. - let url = format!( - "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={}", - api_key - ); - let body = serde_json::json!({ - "contents": [{"parts": [{"text": a.query}]}], - "tools": [{"google_search": {}}], - }); - - let client = http_client(); - let response = client.send_json("POST", &url, &[], &body).await - .context("gemini API request failed")?; - let status = response.status(); - if !status.is_success() { - let err_body = response.text().await.unwrap_or_default(); - let n = err_body.floor_char_boundary(err_body.len().min(500)); - anyhow::bail!("gemini_search HTTP {}: {}", status, &err_body[..n]); - } - - let parsed: GeminiResponse = response.json().await - .context("gemini response parse failed")?; - - let candidate = parsed.candidates.into_iter().next() - .context("gemini returned no candidates")?; - - let summary: String = candidate.content.parts.iter() - .filter_map(|p| p.text.as_deref()) - .collect::>() - .join(""); - - let mut out = summary.trim().to_string(); - - if let Some(meta) = candidate.grounding_metadata { - let sources: Vec = meta.grounding_chunks.iter().enumerate() - .filter_map(|(i, c)| c.web.as_ref().map(|w| { - let title = w.title.as_deref().unwrap_or("(untitled)"); - let uri = w.uri.as_deref().unwrap_or(""); - format!(" [{}] {} — {}", i + 1, title, uri) - })) - .collect(); - if !sources.is_empty() { - out.push_str("\n\nSources:\n"); - out.push_str(&sources.join("\n")); - } - } - - Ok(super::truncate_output(out, 30000)) -} - -#[derive(Deserialize)] -struct GeminiResponse { - #[serde(default)] - candidates: Vec, -} - -#[derive(Deserialize)] -struct GeminiCandidate { - content: GeminiContent, - #[serde(rename = "groundingMetadata", default)] - grounding_metadata: Option, -} - -#[derive(Deserialize)] -struct GeminiContent { - #[serde(default)] - parts: Vec, -} - -#[derive(Deserialize)] -struct GeminiPart { - #[serde(default)] - text: Option, -} - -#[derive(Deserialize)] -struct GeminiGroundingMetadata { - #[serde(rename = "groundingChunks", default)] - grounding_chunks: Vec, -} - -#[derive(Deserialize)] -struct GeminiGroundingChunk { - #[serde(default)] - web: Option, -} - -#[derive(Deserialize)] -struct GeminiWebSource { - #[serde(default)] - uri: Option, - #[serde(default)] - title: Option, -} - // ── Helpers ───────────────────────────────────────────────────── fn http_client() -> crate::agent::api::http::HttpClient { diff --git a/src/bin/fix-timestamps.rs b/src/bin/fix-timestamps.rs deleted file mode 100644 index 31a8788..0000000 --- a/src/bin/fix-timestamps.rs +++ /dev/null @@ -1,180 +0,0 @@ -// fix-timestamps: One-off migration for ~/.consciousness/agent-sessions/ -// conversation.jsonl. -// -// Before Branch nodes carried their own timestamps, early entries were -// serialized with missing/null timestamp fields — they deserialize as -// UNIX_EPOCH via the (now-to-be-removed) deserialize_timestamp_or_epoch -// fallback. Training needs every entry to have a unique timestamp to -// dedup already-trained responses. -// -// Walks the file, synthesizes timestamps for any entry stuck at -// UNIX_EPOCH by linear interpolation between surrounding real -// timestamps. For child leaves inside a Branch, derives timestamps -// from the parent with a tiny per-child offset. -// -// SAFETY: reads from argv[1], writes to argv[1].tmp, renames into -// place. Keep a .bak copy before running. -// -// Usage: fix-timestamps - -use std::io::{BufRead, BufReader, BufWriter, Write}; -use std::path::PathBuf; - -use anyhow::{Context, Result}; -use chrono::{DateTime, Duration, Utc}; - -use consciousness::agent::context::AstNode; - -fn main() -> Result<()> { - let path: PathBuf = std::env::args().nth(1) - .context("usage: fix-timestamps ")?.into(); - - let f = std::fs::File::open(&path) - .with_context(|| format!("open {}", path.display()))?; - let reader = BufReader::new(f); - - let mut nodes: Vec = Vec::new(); - for (i, line) in reader.lines().enumerate() { - let line = line?; - if line.trim().is_empty() { continue; } - let node: AstNode = serde_json::from_str(&line) - .with_context(|| format!("line {}: parse", i + 1))?; - nodes.push(node); - } - println!("read {} entries", nodes.len()); - - fix_top_level_timestamps(&mut nodes); - for node in &mut nodes { - propagate_to_children(node); - } - - // Ensure uniqueness — real timestamps can collide when two entries - // were written in the same ns; synthesized ones can also overlap. - // Bump colliding ns by 1 until unique. - let mut seen = std::collections::HashSet::new(); - let mut bumps = 0usize; - for (i, node) in nodes.iter_mut().enumerate() { - let ts = top_ts(node); - assert!(ts > DateTime::::UNIX_EPOCH, - "entry {}: still UNIX_EPOCH", i); - let mut ns = ts.timestamp_nanos_opt().expect("ts in i64 ns range"); - let mut bumped = false; - while !seen.insert(ns) { - ns += 1; - bumped = true; - bumps += 1; - } - if bumped { - set_top_ts(node, DateTime::::from_timestamp_nanos(ns)); - } - } - println!("all {} timestamps real and unique ({} ns bumps)", - nodes.len(), bumps); - - let tmp = path.with_extension("jsonl.tmp"); - { - let f = std::fs::File::create(&tmp) - .with_context(|| format!("create {}", tmp.display()))?; - let mut w = BufWriter::new(f); - for node in &nodes { - serde_json::to_writer(&mut w, node)?; - w.write_all(b"\n")?; - } - w.flush()?; - } - std::fs::rename(&tmp, &path) - .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?; - println!("wrote {}", path.display()); - - Ok(()) -} - -fn top_ts(node: &AstNode) -> DateTime { - match node { - AstNode::Leaf(leaf) => leaf.timestamp(), - AstNode::Branch { timestamp, .. } => *timestamp, - } -} - -fn set_top_ts(node: &mut AstNode, ts: DateTime) { - match node { - AstNode::Leaf(leaf) => *leaf = leaf.clone().with_timestamp(ts), - AstNode::Branch { timestamp, .. } => *timestamp = ts, - } -} - -/// Fill in missing top-level timestamps. Strategy: -/// - If two real timestamps bracket a run of missing ones, linearly -/// interpolate between them. -/// - If missing ones precede the first real one, back-fill using -/// (first_real - N·1µs). -/// - If missing ones follow the last real one, forward-fill. -/// - If no real timestamps exist at all, synthesize from now() going -/// backwards. -fn fix_top_level_timestamps(nodes: &mut [AstNode]) { - let real: Vec<(usize, DateTime)> = nodes.iter().enumerate() - .filter(|(_, n)| top_ts(n) > DateTime::::UNIX_EPOCH) - .map(|(i, n)| (i, top_ts(n))) - .collect(); - - if real.is_empty() { - let now = Utc::now(); - let len = nodes.len(); - for (i, node) in nodes.iter_mut().enumerate() { - let ts = now - Duration::microseconds((len - i) as i64); - set_top_ts(node, ts); - } - return; - } - - // Helper: bisect real[] for the nearest real entries around idx. - let find_bracket = |idx: usize| -> (Option<(usize, DateTime)>, - Option<(usize, DateTime)>) { - let pos = real.binary_search_by_key(&idx, |(i, _)| *i); - let (prior_pos, next_pos) = match pos { - Ok(p) => (Some(p), Some(p)), - Err(p) => ( - if p == 0 { None } else { Some(p - 1) }, - if p >= real.len() { None } else { Some(p) }, - ), - }; - (prior_pos.map(|p| real[p]), next_pos.map(|p| real[p])) - }; - - for i in 0..nodes.len() { - if top_ts(&nodes[i]) > DateTime::::UNIX_EPOCH { - continue; - } - let (prior, next) = find_bracket(i); - let new_ts = match (prior, next) { - (Some((pi, pt)), Some((ni, nt))) if pi != ni => { - // Linear interpolate. - let span_ns = (nt - pt).num_nanoseconds().unwrap_or(0); - let offset_ns = span_ns * (i - pi) as i64 / (ni - pi) as i64; - pt + Duration::nanoseconds(offset_ns) - } - (Some((pi, pt)), _) => { - pt + Duration::microseconds((i - pi) as i64) - } - (None, Some((ni, nt))) => { - nt - Duration::microseconds((ni - i) as i64) - } - (None, None) => unreachable!(), - }; - set_top_ts(&mut nodes[i], new_ts); - } -} - -/// For every Branch, ensure each child Leaf has a timestamp. If missing, -/// use parent.ts + child_idx·1ns so siblings stay unique but close. -fn propagate_to_children(node: &mut AstNode) { - if let AstNode::Branch { timestamp, children, .. } = node { - let parent_ts = *timestamp; - for (ci, child) in children.iter_mut().enumerate() { - if top_ts(child) <= DateTime::::UNIX_EPOCH { - set_top_ts(child, parent_ts + Duration::nanoseconds(ci as i64)); - } - propagate_to_children(child); - } - } -} diff --git a/src/cli/node.rs b/src/cli/node.rs index c4305a7..5472505 100644 --- a/src/cli/node.rs +++ b/src/cli/node.rs @@ -197,7 +197,7 @@ pub async fn cmd_load_context(stats: bool) -> Result<()> { return Ok(()); } - println!("=== MEMORY SYSTEM ({}) ===", crate::config::app().assistant_name); + println!("=== MEMORY SYSTEM ({}) ===", cfg.assistant_name); if !personality.is_empty() { println!("--- personality_nodes ({}) ---", personality.len()); diff --git a/src/config.rs b/src/config.rs index 209bdc1..9f9ad9a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,9 @@ // Single config file: ~/.consciousness/config.json5 // Memory settings in the "memory" section (Config) // Agent/backend settings at top level (AppConfig) +// +// Legacy fallback: ~/.consciousness/config.jsonl +// Env override: POC_MEMORY_CONFIG use std::collections::HashMap; use std::path::PathBuf; @@ -26,12 +29,11 @@ pub fn config_path() -> PathBuf { static CONFIG: OnceLock>> = OnceLock::new(); +fn default_context_window() -> usize { 128_000 } fn default_stream_timeout() -> u64 { 60 } +fn default_scoring_chunk_tokens() -> usize { 50_000 } fn default_scoring_interval_secs() -> u64 { 3600 } // 1 hour fn default_scoring_response_window() -> usize { 100 } -fn default_surface_hooks() -> Vec { - vec!["UserPromptSubmit".into(), "PostToolUse".into(), "Stop".into()] -} fn default_node_weight() -> f64 { 0.7 } fn default_edge_decay() -> f64 { 0.3 } fn default_max_hops() -> u32 { 3 } @@ -43,6 +45,8 @@ fn default_identity_dir() -> PathBuf { #[derive(Debug, Clone, Deserialize)] #[serde(default)] pub struct Config { + pub user_name: String, + pub assistant_name: String, #[serde(deserialize_with = "deserialize_path")] pub data_dir: PathBuf, #[serde(default = "default_identity_dir", deserialize_with = "deserialize_path")] @@ -58,27 +62,50 @@ pub struct Config { /// Nodes loaded into subconscious agent context #[serde(default)] pub agent_nodes: Vec, + pub journal_days: u32, + pub journal_max: usize, pub llm_concurrency: usize, + pub agent_budget: usize, + #[serde(deserialize_with = "deserialize_path")] + pub prompts_dir: PathBuf, + /// Resolved from agent_model → models → backend (not in config directly) + #[serde(skip)] + pub api_base_url: Option, + #[serde(skip)] + pub api_key: Option, + #[serde(skip)] + pub api_model: Option, + #[serde(skip, default = "default_context_window")] + pub api_context_window: usize, + /// Used to resolve API settings, not stored on Config + #[serde(default)] + agent_model: Option, /// Stream chunk timeout in seconds (no data = timeout). #[serde(default = "default_stream_timeout")] pub api_stream_timeout_secs: u64, + /// Max tokens per chunk for memory scoring logprobs calls. + #[serde(default = "default_scoring_chunk_tokens")] + pub scoring_chunk_tokens: usize, /// How often to re-score memory nodes (seconds). Default: 3600 (1 hour). #[serde(default = "default_scoring_interval_secs")] pub scoring_interval_secs: u64, /// Number of assistant responses to score per memory. Default: 50. #[serde(default = "default_scoring_response_window")] pub scoring_response_window: usize, + pub api_reasoning: String, pub agent_types: Vec, #[serde(default)] pub mcp_servers: Vec, #[serde(default)] pub lsp_servers: Vec, + /// Surface agent timeout in seconds. + #[serde(default)] + pub surface_timeout_secs: Option, /// Max conversation bytes to include in surface agent context. #[serde(default)] pub surface_conversation_bytes: Option, - /// Claude Code hook events that trigger agent cycles (surface-observe, - /// reflect, journal). Read by consciousness-claude/src/hook.rs. - #[serde(default = "default_surface_hooks")] + /// Hook events that trigger the surface agent. + #[serde(default)] pub surface_hooks: Vec, // Spreading activation parameters @@ -96,22 +123,36 @@ impl Default for Config { fn default() -> Self { let home = dirs::home_dir().unwrap_or_default(); Self { + user_name: "User".to_string(), + assistant_name: "Assistant".to_string(), data_dir: home.join(".consciousness/memory"), identity_dir: home.join(".consciousness/identity"), projects_dir: home.join(".claude/projects"), protected_nodes: Vec::new(), personality_nodes: vec!["identity".into(), "core-practices".into()], agent_nodes: vec!["identity".into(), "core-practices".into()], + journal_days: 7, + journal_max: 20, llm_concurrency: 1, + agent_budget: 1000, + prompts_dir: home.join(".consciousness/prompts"), + api_base_url: None, + api_key: None, + api_model: None, + api_context_window: default_context_window(), api_stream_timeout_secs: default_stream_timeout(), + scoring_chunk_tokens: default_scoring_chunk_tokens(), scoring_interval_secs: default_scoring_interval_secs(), scoring_response_window: default_scoring_response_window(), + agent_model: None, + api_reasoning: "high".to_string(), agent_types: vec![ "linker".into(), "organize".into(), "distill".into(), "separator".into(), "split".into(), ], + surface_timeout_secs: None, surface_conversation_bytes: None, - surface_hooks: default_surface_hooks(), + surface_hooks: vec![], mcp_servers: vec![], lsp_servers: vec![], default_node_weight: default_node_weight(), @@ -124,20 +165,41 @@ impl Default for Config { impl Config { fn load_from_file() -> Self { - Self::try_load_shared().unwrap_or_default() + if let Some(config) = Self::try_load_shared() { + return config; + } + Self::load_legacy_jsonl() } /// Load from shared config. Memory settings in the "memory" section; /// API settings resolved from models + backend configuration. fn try_load_shared() -> Option { let content = std::fs::read_to_string(config_path()).ok()?; - let root: serde_json::Value = json_five::from_str(&content).ok()?; + let root: serde_json::Value = json5::from_str(&content).ok()?; let mem_value = root.get("memory")?; let mut config: Config = serde_json::from_value(mem_value.clone()).ok()?; config.llm_concurrency = config.llm_concurrency.max(1); - // Top-level sections (not inside "memory"). + // Resolve API settings: agent_model → models → backend + if let Some(model_name) = &config.agent_model + && let Some(model_cfg) = root.get("models").and_then(|m| m.get(model_name.as_str())) { + let backend_name = model_cfg.get("backend").and_then(|v| v.as_str()).unwrap_or(""); + let model_id = model_cfg.get("model_id").and_then(|v| v.as_str()).unwrap_or(""); + + if let Some(backend) = root.get(backend_name) { + config.api_base_url = backend.get("base_url") + .and_then(|v| v.as_str()).map(String::from); + config.api_key = backend.get("api_key") + .and_then(|v| v.as_str()).map(String::from); + } + config.api_model = Some(model_id.to_string()); + if let Some(cw) = model_cfg.get("context_window").and_then(|v| v.as_u64()) { + config.api_context_window = cw as usize; + } + } + + // Top-level config sections (not inside "memory") if let Some(servers) = root.get("lsp_servers") { config.lsp_servers = serde_json::from_value(servers.clone()).unwrap_or_default(); } @@ -147,6 +209,11 @@ impl Config { Some(config) } + + /// Load from legacy JSONL config — deprecated, just return defaults. + fn load_legacy_jsonl() -> Self { + Config::default() + } } /// Get the global memory config (cheap Arc clone). @@ -170,87 +237,27 @@ pub fn reload() -> bool { changed } -/// Spawn a background thread that watches `~/.consciousness/config.json5` -/// and reloads both the memory Config and the global AppConfig whenever -/// the file changes on disk. Lets edits from vim / F6 hotkeys / manual -/// tweaks land live without restarting the process. -pub fn watch_config(cli: crate::user::CliArgs) { - use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode}; - - let path = config_path(); - // Watch the parent directory — editors often replace-via-rename, so - // watching the file itself misses the new inode. - let Some(parent) = path.parent().map(|p| p.to_path_buf()) else { - crate::dbglog!("[config] no parent for {}, skipping watch", path.display()); - return; - }; - - std::thread::Builder::new() - .name("config-watcher".into()) - .spawn(move || { - let (tx, rx) = std::sync::mpsc::channel(); - let mut debouncer = match new_debouncer(std::time::Duration::from_millis(200), tx) { - Ok(d) => d, - Err(e) => { - crate::dbglog!("[config] watcher setup failed: {}", e); - return; - } - }; - if let Err(e) = debouncer.watcher() - .watch(&parent, RecursiveMode::NonRecursive) - { - crate::dbglog!("[config] watch({}) failed: {}", parent.display(), e); - return; - } - crate::dbglog!("[config] watching {}", path.display()); - - while let Ok(res) = rx.recv() { - let Ok(events) = res else { continue; }; - if !events.iter().any(|e| e.path == path) { continue; } - - // Reload both halves. - let mem_changed = reload(); - let app_changed = match build_figment(&cli).extract::() { - Ok(app) => { - install_app(app); - true - } - Err(e) => { - crate::dbglog!("[config] reload: AppConfig parse failed: {}", e); - false - } - }; - crate::dbglog!("[config] reloaded (memory_changed={}, app_changed={})", - mem_changed, app_changed); - } - }) - .ok(); -} - // ============================================================ // Agent config (top-level settings) // ============================================================ #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppConfig { - #[serde(default = "default_user_name")] - pub user_name: String, - #[serde(default = "default_assistant_name")] - pub assistant_name: String, - /// Named model endpoints — credentials, base URL, and model id bundled - /// into one entry per backend. Keyed by name, selected by - /// `default_backend` or by `--model ` on the CLI. + pub backend: String, + pub anthropic: BackendConfig, + pub openrouter: BackendConfig, #[serde(default)] - pub backends: HashMap, - #[serde(default)] - pub default_backend: String, + pub deepinfra: BackendConfig, + pub prompts: PromptConfig, pub debug: bool, pub compaction: CompactionConfig, pub dmn: DmnConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub memory_project: Option, #[serde(default)] - pub learn: LearnConfig, - #[serde(default)] - pub compare: CompareConfig, + pub models: HashMap, + #[serde(default = "default_model_name")] + pub default_model: String, #[serde(default)] pub mcp_servers: Vec, #[serde(default)] @@ -277,17 +284,32 @@ pub struct LspServerConfig { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct BackendConfig { - /// API key for the backend. #[serde(default)] pub api_key: String, - /// Base URL for the backend's OpenAI-compatible endpoint. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] pub base_url: Option, - /// Model identifier sent to the API. - pub model_id: String, - /// Context window size in tokens. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub context_window: Option, +} + +impl BackendConfig { + fn resolve(&self, default_base: &str) -> Result<(String, String, String)> { + if self.api_key.is_empty() { + anyhow::bail!( + "No API key. Set it in {} or use --api-key", + config_path().display() + ); + } + let base = self.base_url.clone() + .unwrap_or_else(|| default_base.to_string()); + Ok((base, self.api_key.clone(), self.model.clone())) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PromptConfig { + pub anthropic: String, + pub other: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -302,68 +324,65 @@ pub struct DmnConfig { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LearnConfig { - /// Divergence threshold — responses scoring above this become - /// fine-tuning candidates. Lower = more sensitive. - #[serde(default = "default_learn_threshold")] - pub threshold: f64, - /// Whether to generate "what would the model have said without - /// memories" alternates alongside each scoring run. Expensive — - /// one full streaming generation per candidate. +pub struct ModelConfig { + /// Backend name ("anthropic" or "openrouter") + pub backend: String, + /// Model identifier sent to the API + pub model_id: String, + /// Instruction file ("CLAUDE.md" or "POC.md"). #[serde(default)] - pub generate_alternates: bool, -} - -fn default_learn_threshold() -> f64 { 1.0 } - -impl Default for LearnConfig { - fn default() -> Self { - Self { - threshold: default_learn_threshold(), - generate_alternates: false, - } - } -} - -/// 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. + pub prompt_file: Option, + /// Context window size in tokens. #[serde(default)] - pub test_backend: String, + pub context_window: Option, } -fn default_user_name() -> String { "User".into() } -fn default_assistant_name() -> String { "Assistant".into() } - impl Default for AppConfig { fn default() -> Self { Self { - user_name: default_user_name(), - assistant_name: default_assistant_name(), - backends: HashMap::new(), - default_backend: String::new(), + backend: "openrouter".to_string(), + anthropic: BackendConfig { + api_key: String::new(), + model: "claude-opus-4-6-20250918".to_string(), + base_url: None, + }, + openrouter: BackendConfig { + api_key: String::new(), + model: "qwen/qwen3.5-397b-a17b".to_string(), + base_url: Some("https://openrouter.ai/api/v1".to_string()), + }, + deepinfra: BackendConfig { + api_key: String::new(), + model: String::new(), + base_url: Some("https://api.deepinfra.com/v1/openai".to_string()), + }, + prompts: PromptConfig { + anthropic: "CLAUDE.md".to_string(), + other: "POC.md".to_string(), + }, debug: false, compaction: CompactionConfig { hard_threshold_pct: 90, soft_threshold_pct: 80, }, dmn: DmnConfig { max_turns: 20 }, - learn: LearnConfig::default(), - compare: CompareConfig::default(), + memory_project: None, + models: HashMap::new(), + default_model: String::new(), mcp_servers: Vec::new(), lsp_servers: Vec::new(), } } } +fn default_model_name() -> String { String::new() } + /// Resolved, ready-to-use agent session config. pub struct SessionConfig { pub api_base: String, pub api_key: String, pub model: String, + pub prompt_file: String, /// Identity/personality nodes as (name, content) pairs. pub context_parts: Vec<(String, String)>, pub session_dir: PathBuf, @@ -379,21 +398,36 @@ pub struct ResolvedModel { pub api_base: String, pub api_key: String, pub model_id: String, + pub prompt_file: String, pub context_window: Option, } impl AppConfig { /// Resolve the active backend and assemble prompts into a SessionConfig. pub async fn resolve(&self, cli: &crate::user::CliArgs) -> Result { - if self.backends.is_empty() { - anyhow::bail!( - "no backends configured in {}. Add a `backends` section with at least one entry.", - config_path().display() - ); - } + let (api_base, api_key, model, prompt_file); - let name = cli.model.as_deref().unwrap_or(&self.default_backend); - let resolved = self.resolve_model(name)?; + if !self.models.is_empty() { + let model_name = cli.model.as_deref().unwrap_or(&self.default_model); + let resolved = self.resolve_model(model_name)?; + api_base = resolved.api_base; + api_key = resolved.api_key; + model = resolved.model_id; + prompt_file = resolved.prompt_file; + } else { + let (base, key, mdl) = match self.backend.as_str() { + "anthropic" => self.anthropic.resolve("https://api.anthropic.com"), + _ => self.openrouter.resolve("https://openrouter.ai/api/v1"), + }?; + api_base = base; + api_key = key; + model = mdl; + prompt_file = if self.backend == "anthropic" { + self.prompts.anthropic.clone() + } else { + self.prompts.other.clone() + }; + } let personality_nodes = get().personality_nodes.clone(); let context_parts = crate::mind::identity::personality_nodes(&personality_nodes).await; @@ -404,13 +438,11 @@ impl AppConfig { std::fs::create_dir_all(&session_dir).ok(); // CLI --api-base and --api-key override everything - let api_base = cli.api_base.clone().unwrap_or(resolved.api_base); - let api_key = cli.api_key.clone().unwrap_or(resolved.api_key); + let api_base = cli.api_base.clone().unwrap_or(api_base); + let api_key = cli.api_key.clone().unwrap_or(api_key); Ok(SessionConfig { - api_base, - api_key, - model: resolved.model_id, + api_base, api_key, model, prompt_file, context_parts, session_dir, app: self.clone(), @@ -418,33 +450,55 @@ impl AppConfig { }) } - /// Look up a named backend and resolve its credentials. + /// Look up a named model and resolve its credentials from the backend config. pub fn resolve_model(&self, name: &str) -> Result { - let b = self.backends.get(name) + let model = self.models.get(name) .ok_or_else(|| anyhow::anyhow!( - "Unknown backend '{}'. Available: {}", + "Unknown model '{}'. Available: {}", name, self.model_names().join(", "), ))?; - let api_base = b.base_url.clone() - .ok_or_else(|| anyhow::anyhow!( - "backends.{}.base_url not set in {}", - name, config_path().display() - ))?; + let (api_base, api_key) = match model.backend.as_str() { + "anthropic" => ( + self.anthropic.base_url.clone() + .unwrap_or_else(|| "https://api.anthropic.com".to_string()), + self.anthropic.api_key.clone(), + ), + "deepinfra" => ( + self.deepinfra.base_url.clone() + .unwrap_or_else(|| "https://api.deepinfra.com/v1/openai".to_string()), + self.deepinfra.api_key.clone(), + ), + _ => ( + self.openrouter.base_url.clone() + .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()), + self.openrouter.api_key.clone(), + ), + }; + + let prompt_file = model.prompt_file.clone() + .unwrap_or_else(|| { + if model.backend == "anthropic" { + self.prompts.anthropic.clone() + } else { + self.prompts.other.clone() + } + }); Ok(ResolvedModel { name: name.to_string(), api_base, - api_key: b.api_key.clone(), - model_id: b.model_id.clone(), - context_window: b.context_window, + api_key, + model_id: model.model_id.clone(), + prompt_file, + context_window: model.context_window, }) } - /// List available backend names, sorted. + /// List available model names, sorted. pub fn model_names(&self) -> Vec { - let mut names: Vec<_> = self.backends.keys().cloned().collect(); + let mut names: Vec<_> = self.models.keys().cloned().collect(); names.sort(); names } @@ -464,7 +518,7 @@ impl Provider for Json5File { fn data(&self) -> figment::Result> { match std::fs::read_to_string(&self.0) { Ok(content) => { - let value: figment::value::Value = json_five::from_str(&content) + let value: figment::value::Value = json5::from_str(&content) .map_err(|e| figment::Error::from(format!("{}: {}", self.0.display(), e)))?; Serialized::defaults(value).data() } @@ -486,6 +540,11 @@ fn build_figment(cli: &crate::user::CliArgs) -> Figment { let mut f = Figment::from(Serialized::defaults(AppConfig::default())) .merge(Json5File(config_path())); + merge_opt!(f, cli.backend, "backend"); + merge_opt!(f, cli.model, "anthropic.model", "openrouter.model"); + merge_opt!(f, cli.api_key, "anthropic.api_key", "openrouter.api_key"); + merge_opt!(f, cli.api_base, "anthropic.base_url", "openrouter.base_url"); + merge_opt!(f, cli.memory_project, "memory_project"); merge_opt!(f, cli.dmn_max_turns, "dmn.max_turns"); if cli.debug { f = f.merge(Serialized::default("debug", true)); @@ -495,46 +554,12 @@ fn build_figment(cli: &crate::user::CliArgs) -> Figment { } /// Load just the AppConfig — no validation, no prompt assembly. -/// Also installs the loaded AppConfig into the global cache so -/// `config::app()` is available everywhere. pub fn load_app(cli: &crate::user::CliArgs) -> Result<(AppConfig, Figment)> { let figment = build_figment(cli); let app: AppConfig = figment.extract().context("Failed to load configuration")?; - install_app(app.clone()); Ok((app, figment)) } -// ============================================================ -// Global AppConfig cache (writable, for runtime-mutable settings -// like learn.threshold that F6 edits via config_writer). -// ============================================================ - -static APP_CONFIG: OnceLock> = OnceLock::new(); - -fn install_app(app: AppConfig) { - let slot = APP_CONFIG.get_or_init(|| RwLock::new(app.clone())); - *slot.write().unwrap() = app; -} - -/// Current AppConfig, held under a read lock. Reads should be brief -/// (no holding across await / long work) to avoid starving writers. -/// Panics if called before load_app — which runs once at startup. -pub fn app() -> std::sync::RwLockReadGuard<'static, AppConfig> { - APP_CONFIG - .get() - .expect("config::app() called before load_app()") - .read() - .unwrap() -} - -/// Mutate the cached AppConfig in place. Used by config_writer to keep -/// the in-memory view in sync with disk after surgical edits to -/// ~/.consciousness/config.json5. -pub fn update_app(f: impl FnOnce(&mut AppConfig)) { - let slot = APP_CONFIG.get().expect("update_app before load_app"); - f(&mut *slot.write().unwrap()); -} - /// Load the full config: figment → AppConfig → resolve backend → assemble prompts. pub async fn load_session(cli: &crate::user::CliArgs) -> Result<(SessionConfig, Figment)> { let (app, figment) = load_app(cli)?; @@ -560,28 +585,38 @@ pub fn show_config(app: &AppConfig, figment: &Figment) { } println!("# Effective configuration\n"); - println!("user_name: {:?} ({})", app.user_name, src(figment, "user_name")); - println!("assistant_name: {:?} ({})", app.assistant_name, src(figment, "assistant_name")); + println!("backend: {:?} ({})", app.backend, src(figment, "backend")); + for (name, b) in [("anthropic", &app.anthropic), ("openrouter", &app.openrouter)] { + println!("\n{}:", name); + println!(" api_key: {} ({})", mask(&b.api_key), src(figment, &format!("{name}.api_key"))); + println!(" model: {:?} ({})", b.model, src(figment, &format!("{name}.model"))); + if let Some(ref url) = b.base_url { + println!(" base_url: {:?} ({})", url, src(figment, &format!("{name}.base_url"))); + } + } + println!("\nprompts:"); + println!(" anthropic: {:?} ({})", app.prompts.anthropic, src(figment, "prompts.anthropic")); + println!(" other: {:?} ({})", app.prompts.other, src(figment, "prompts.other")); println!("\ndebug: {} ({})", app.debug, src(figment, "debug")); println!("\ncompaction:"); println!(" hard_threshold_pct: {} ({})", app.compaction.hard_threshold_pct, src(figment, "compaction.hard_threshold_pct")); println!(" soft_threshold_pct: {} ({})", app.compaction.soft_threshold_pct, src(figment, "compaction.soft_threshold_pct")); println!("\ndmn:"); println!(" max_turns: {} ({})", app.dmn.max_turns, src(figment, "dmn.max_turns")); - println!("\ndefault_backend: {:?} ({})", app.default_backend, src(figment, "default_backend")); - if !app.backends.is_empty() { - println!("\nbackends:"); - let mut names: Vec<_> = app.backends.keys().cloned().collect(); - names.sort(); - for name in names { - let b = &app.backends[&name]; + if let Some(ref p) = app.memory_project { + println!("\nmemory_project: {:?} ({})", p, src(figment, "memory_project")); + } + println!("\ndefault_model: {:?}", app.default_model); + if !app.models.is_empty() { + println!("\nmodels:"); + for (name, m) in &app.models { println!(" {}:", name); - println!(" api_key: {} ({})", mask(&b.api_key), src(figment, &format!("backends.{name}.api_key"))); - if let Some(ref url) = b.base_url { - println!(" base_url: {:?} ({})", url, src(figment, &format!("backends.{name}.base_url"))); + println!(" backend: {:?}", m.backend); + println!(" model_id: {:?}", m.model_id); + if let Some(ref pf) = m.prompt_file { + println!(" prompt_file: {:?}", pf); } - println!(" model_id: {:?}", b.model_id); - if let Some(cw) = b.context_window { + if let Some(cw) = m.context_window { println!(" context_window: {}", cw); } } diff --git a/src/config_writer.rs b/src/config_writer.rs deleted file mode 100644 index 079449f..0000000 --- a/src/config_writer.rs +++ /dev/null @@ -1,448 +0,0 @@ -// config_writer.rs — Surgical edits to ~/.consciousness/config.json5 -// -// Uses json-five's round-trip parser to mutate specific fields while -// preserving the surrounding comments, whitespace, and formatting. - -use std::path::Path; - -use anyhow::{anyhow, Context as _, Result}; -use json_five::rt::parser::{ - from_str, JSONKeyValuePair, JSONObjectContext, JSONValue, KeyValuePairContext, -}; - -use crate::config::config_path; - -/// Read the config, apply `mutate` to the root JSONValue, write it back atomically. -fn edit_config Result<()>>(mutate: F) -> Result<()> { - let path = config_path(); - let src = std::fs::read_to_string(&path) - .with_context(|| format!("read {}", path.display()))?; - - let mut text = from_str(&src) - .map_err(|e| anyhow!("parse {}: {}", path.display(), e))?; - mutate(&mut text.value)?; - - write_atomic(&path, &text.to_string()) -} - -fn write_atomic(path: &Path, content: &str) -> Result<()> { - let parent = path.parent() - .ok_or_else(|| anyhow!("config path has no parent: {}", path.display()))?; - let tmp = parent.join(format!( - ".{}.tmp", - path.file_name().unwrap_or_default().to_string_lossy(), - )); - std::fs::write(&tmp, content) - .with_context(|| format!("write {}", tmp.display()))?; - std::fs::rename(&tmp, path) - .with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?; - Ok(()) -} - -/// Match a key JSONValue against a string name. JSON5 allows keys to be -/// unquoted identifiers or single/double-quoted strings. -fn key_matches(key: &JSONValue, name: &str) -> bool { - match key { - JSONValue::Identifier(s) - | JSONValue::DoubleQuotedString(s) - | JSONValue::SingleQuotedString(s) => s == name, - _ => false, - } -} - -/// Find (or create) a child object under `parent`, returning a mutable borrow -/// of its key_value_pairs vector. -/// Append a new kvp to `object`, setting whitespace so the output is -/// multi-line with the given indentation: -/// -/// ```text -/// {first_key: first_val,} -/// ``` -/// -/// If `object` already has kvps, the separator between the last one and -/// ours goes in the prior kvp's wsc.3. If we're the first kvp, the -/// lead-in after `{` goes in the object's own wsc.0. -fn append_kvp_pretty( - object: &mut JSONValue, - key: JSONValue, - value: JSONValue, - inner_indent: &str, - outer_indent: &str, -) -> Result<()> { - let (pairs, ctx) = match object { - JSONValue::JSONObject { key_value_pairs, context } => { - let ctx = context.get_or_insert_with(|| JSONObjectContext { - wsc: (String::new(),), - }); - (key_value_pairs, ctx) - } - _ => return Err(anyhow!("not an object")), - }; - - if pairs.is_empty() { - ctx.wsc.0 = format!("\n{}", inner_indent); - } else { - let prev = pairs.last_mut().unwrap(); - let prev_ctx = prev.context.get_or_insert_with(|| KeyValuePairContext { - wsc: (String::new(), String::from(" "), String::new(), None), - }); - prev_ctx.wsc.3 = Some(format!("\n{}", inner_indent)); - } - - pairs.push(JSONKeyValuePair { - key, - value, - context: Some(KeyValuePairContext { - wsc: ( - String::new(), - String::from(" "), - String::new(), - Some(format!("\n{}", outer_indent)), - ), - }), - }); - - Ok(()) -} - -/// Find or create a child object under `parent`. Returns the index of -/// the kvp in parent's key_value_pairs so the caller can re-borrow -/// afterward. -fn get_or_create_object_idx( - parent: &mut JSONValue, - section: &str, - inner_indent: &str, - outer_indent: &str, -) -> Result { - let existing = match parent { - JSONValue::JSONObject { key_value_pairs, .. } => { - key_value_pairs.iter() - .position(|kvp| key_matches(&kvp.key, section)) - } - _ => return Err(anyhow!("config root is not an object")), - }; - - if let Some(i) = existing { - return Ok(i); - } - - append_kvp_pretty( - parent, - JSONValue::Identifier(section.to_string()), - JSONValue::JSONObject { - key_value_pairs: Vec::new(), - context: Some(JSONObjectContext { wsc: (String::new(),) }), - }, - inner_indent, - outer_indent, - )?; - - match parent { - JSONValue::JSONObject { key_value_pairs, .. } => Ok(key_value_pairs.len() - 1), - _ => unreachable!(), - } -} - -/// Set `section.key` to a literal scalar value (e.g., "1e-7", "42", "true"). -/// The literal is parsed as JSON5 so we preserve its source-form on round-trip. -pub fn set_scalar(section: &str, key: &str, literal: &str) -> Result<()> { - let value = parse_scalar_literal(literal)?; - edit_config(|root| { - // New top-level sections sit at column 4 (inside root `{`), - // and the root's closing `}` sits at column 0. - let section_idx = get_or_create_object_idx(root, section, " ", "")?; - - let section_value = match root { - JSONValue::JSONObject { key_value_pairs, .. } => { - &mut key_value_pairs[section_idx].value - } - _ => unreachable!(), - }; - - // Update in place if the key already exists. - if let JSONValue::JSONObject { key_value_pairs, .. } = section_value { - if let Some(kvp) = key_value_pairs.iter_mut() - .find(|k| key_matches(&k.key, key)) - { - kvp.value = value; - return Ok(()); - } - } - - // Append a new kvp. Inner keys sit at column 8, the section's - // closing `}` sits at column 4. - append_kvp_pretty( - section_value, - JSONValue::Identifier(key.to_string()), - value, - " ", - " ", - ) - }) -} - -/// Parse a scalar literal by round-tripping it through json-five. Keeps us -/// consistent with whatever scalars the library considers valid (hex, -/// exponents, Infinity, etc.). -fn parse_scalar_literal(literal: &str) -> Result { - let text = from_str(literal) - .map_err(|e| anyhow!("parse literal {:?}: {}", literal, e))?; - match text.value { - JSONValue::JSONObject { .. } | JSONValue::JSONArray { .. } => { - Err(anyhow!("set_scalar only accepts scalar literals, got {:?}", literal)) - } - v => Ok(v), - } -} - -/// Convenience: set `learn.threshold` to the given f64. -pub fn set_learn_threshold(value: f64) -> Result<()> { - // {:e} gives the minimal scientific notation that preserves the value. - set_scalar("learn", "threshold", &format!("{:e}", value))?; - crate::config::update_app(|app| app.learn.threshold = value); - Ok(()) -} - -/// Convenience: set `learn.generate_alternates` to the given bool. -pub fn set_learn_generate_alternates(value: bool) -> Result<()> { - set_scalar("learn", "generate_alternates", - if value { "true" } else { "false" })?; - crate::config::update_app(|app| app.learn.generate_alternates = value); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // In-memory variant of set_scalar — used to test the mutation logic - // without touching disk. - fn set_scalar_inline( - root: &mut JSONValue, - section: &str, - key: &str, - literal: &str, - ) -> Result<()> { - let value = parse_scalar_literal(literal)?; - let section_idx = get_or_create_object_idx(root, section, " ", "")?; - let section_value = match root { - JSONValue::JSONObject { key_value_pairs, .. } => { - &mut key_value_pairs[section_idx].value - } - _ => unreachable!(), - }; - if let JSONValue::JSONObject { key_value_pairs, .. } = section_value { - if let Some(kvp) = key_value_pairs.iter_mut() - .find(|k| key_matches(&k.key, key)) - { - kvp.value = value; - return Ok(()); - } - } - append_kvp_pretty( - section_value, - JSONValue::Identifier(key.to_string()), - value, - " ", - " ", - ) - } - - fn edit_str Result<()>>(src: &str, f: F) -> Result { - let mut text = from_str(src).map_err(|e| anyhow!("{}", e))?; - f(&mut text.value)?; - Ok(text.to_string()) - } - - #[test] - fn replaces_existing_scalar() { - let src = r#"{ - // threshold for learning - learn: { - threshold: 0.001, // the old value - }, -}"#; - let out = edit_str(src, |root| { - set_scalar_inline(root, "learn", "threshold", "1e-7") - }).unwrap(); - assert!(out.contains("1e-7"), "output: {}", out); - assert!(out.contains("// threshold for learning")); - assert!(out.contains("// the old value")); - assert!(!out.contains("0.001")); - } - - #[test] - fn creates_missing_section() { - let src = r#"{ - // comment - memory: { user_name: "Kent" }, -}"#; - let out = edit_str(src, |root| { - set_scalar_inline(root, "learn", "threshold", "1e-7") - }).unwrap(); - assert!(out.contains("learn")); - assert!(out.contains("1e-7")); - assert!(out.contains("// comment")); - assert!(out.contains(r#"user_name: "Kent""#)); - } - - #[test] - fn preserves_comments_in_siblings() { - let src = r#"{ - memory: { - // sensitive setting - user_name: "Kent", // name - }, - learn: { - threshold: 0.5, - }, -}"#; - let out = edit_str(src, |root| { - set_scalar_inline(root, "learn", "threshold", "1e-9") - }).unwrap(); - assert!(out.contains("// sensitive setting")); - assert!(out.contains("// name")); - assert!(out.contains("1e-9")); - assert!(!out.contains("0.5")); - } - - #[test] - fn adds_key_to_existing_empty_section() { - let src = r#"{ - learn: {}, -}"#; - let out = edit_str(src, |root| { - set_scalar_inline(root, "learn", "threshold", "42") - }).unwrap(); - assert!(out.contains("threshold"), "output: {}", out); - assert!(out.contains("42")); - } - - #[test] - fn realistic_config_adds_learn_section() { - // Mirrors the shape of ~/.consciousness/config.json5 — multiple - // sections, comments, mixed tab/space indent, trailing commas. - let src = r#"{ - deepinfra: { - api_key: "bcachefs-agents-2026", - base_url: "http://example/v1", - }, - - // Named models - models: { - "27b": { - backend: "deepinfra", - model_id: "Qwen/Qwen3.5-27B", - }, - }, - - default_model: "27b", - - memory: { - user_name: "Kent", - // Active agent types - agent_types: ["linker", "organize"], - }, - - compaction: { - hard_threshold_pct: 90, - }, -}"#; - let out = edit_str(src, |root| { - set_scalar_inline(root, "learn", "threshold", "1e-7") - }).unwrap(); - - // Core assertions: comments and sibling sections survive. - assert!(out.contains(r#"api_key: "bcachefs-agents-2026""#)); - assert!(out.contains("// Named models")); - assert!(out.contains("// Active agent types")); - assert!(out.contains(r#"user_name: "Kent""#)); - assert!(out.contains("hard_threshold_pct: 90")); - - // New section added. - assert!(out.contains("learn")); - assert!(out.contains("1e-7")); - - // Parse result should parse back without error (real json5 parser). - let reparsed: serde_json::Value = json_five::from_str(&out) - .expect("mutated output must be valid JSON5"); - let threshold = reparsed.pointer("/learn/threshold").expect("learn.threshold exists"); - assert_eq!(threshold.as_f64(), Some(1e-7)); - } - - #[test] - fn realistic_config_updates_existing_threshold() { - let src = r#"{ - learn: { - // The divergence threshold - threshold: 0.001, - }, - memory: { user_name: "Kent" }, -}"#; - let out = edit_str(src, |root| { - set_scalar_inline(root, "learn", "threshold", "5e-8") - }).unwrap(); - assert!(out.contains("5e-8")); - assert!(!out.contains("0.001")); - assert!(out.contains("// The divergence threshold")); - - let reparsed: serde_json::Value = json_five::from_str(&out).unwrap(); - assert_eq!(reparsed.pointer("/learn/threshold").and_then(|v| v.as_f64()), Some(5e-8)); - } - - #[test] - fn new_section_exact_multiline_layout() { - let src = "{\n a: 1,\n}"; - let out = edit_str(src, |root| { - set_scalar_inline(root, "learn", "generate_alternates", "true")?; - set_scalar_inline(root, "learn", "threshold", "1e-7") - }).unwrap(); - - let expected = "\ -{ - a: 1, - learn: { - generate_alternates: true, - threshold: 1e-7, - }, -}"; - assert_eq!(out, expected, "\n--- got ---\n{}\n--- want ---\n{}\n", out, expected); - } - - #[test] - fn new_section_and_key_format_cleanly() { - // The kind of config we actually have in ~/.consciousness - // (top-level sections separated by blank lines, 4-space indent - // for keys within each section). Appending a fresh `learn` - // section with one key should land cleanly, not as - // `learn\n\n :{key\n :value}`. - let src = "{\n memory: {\n user_name: \"Kent\",\n },\n}"; - let out = edit_str(src, |root| { - set_scalar_inline(root, "learn", "generate_alternates", "true") - }).unwrap(); - - // No stray key-to-colon-on-next-line anywhere. - assert!(!out.contains("learn\n"), "learn key wraps: {}", out); - assert!(!out.contains("generate_alternates\n"), - "inner key wraps: {}", out); - - // The output should reparse. - let v: serde_json::Value = json_five::from_str(&out).unwrap(); - assert_eq!( - v.pointer("/learn/generate_alternates").and_then(|x| x.as_bool()), - Some(true), - "output: {}", out, - ); - } - - #[test] - fn roundtrip_stable_without_change() { - let src = r#"{ - // heading - a: 1, - b: { c: 2 }, // inline -}"#; - let text = from_str(src).unwrap(); - assert_eq!(text.to_string(), src); - } -} diff --git a/src/hippocampus/neuro/scoring.rs b/src/hippocampus/neuro/scoring.rs index c9cbb40..5828fd0 100644 --- a/src/hippocampus/neuro/scoring.rs +++ b/src/hippocampus/neuro/scoring.rs @@ -230,6 +230,10 @@ fn consolidation_plan_inner(store: &Store, _detect_interf: bool) -> Consolidatio rationale: Vec::new(), }; + // Active agent types from config + let config = crate::config::get(); + let agent_types: Vec<&str> = config.agent_types.iter().map(|s| s.as_str()).collect(); + // Target: α ≥ 2.5 (healthy scale-free) if alpha < 2.0 { plan.add("linker", 100); @@ -270,6 +274,48 @@ fn consolidation_plan_inner(store: &Store, _detect_interf: bool) -> Consolidatio // Split: handle oversized nodes plan.set("split", 5); + // Distribute agent budget using Elo ratings + let budget = crate::config::get().agent_budget; + let elo_path = crate::config::get().data_dir.join("agent-elo.json"); + if let Ok(elo_json) = std::fs::read_to_string(&elo_path) { + if let Ok(ratings) = serde_json::from_str::>(&elo_json) { + let elos: Vec = agent_types.iter() + .map(|t| ratings.get(*t).copied().unwrap_or(1000.0)) + .collect(); + let min_elo = elos.iter().copied().fold(f64::MAX, f64::min); + + let weights: Vec = elos.iter() + .map(|e| { + let shifted = e - min_elo + 50.0; + shifted * shifted + }) + .collect(); + let total_weight: f64 = weights.iter().sum(); + + let allocate = |w: f64| -> usize { + ((w / total_weight * budget as f64).round() as usize).max(2) + }; + + for (i, agent) in agent_types.iter().enumerate() { + plan.set(agent, allocate(weights[i])); + } + + let summary: Vec = agent_types.iter() + .map(|a| format!("{}={}", a, plan.count(a))) + .collect(); + plan.rationale.push(format!( + "Elo allocation (budget={}): {}", budget, summary.join(" "))); + } + } else { + // No Elo file — use budget with equal distribution + let per_type = budget / agent_types.len(); + for agent in &agent_types { + plan.set(agent, per_type); + } + plan.rationale.push(format!( + "No Elo ratings — equal distribution ({} each, budget={})", per_type, budget)); + } + plan } diff --git a/src/lib.rs b/src/lib.rs index e6411e3..1a71735 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,7 +42,6 @@ pub mod subconscious; // Unified configuration pub mod config; -pub mod config_writer; // Session state pub mod session; diff --git a/src/main.rs b/src/main.rs index f13448c..78bfa4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -482,14 +482,6 @@ async fn main() { let cli = Cli::parse(); - // Some subcommands (e.g. admin load-context) read from the global - // AppConfig. poc-memory has no config CLI flags of its own, so load - // with defaults — figment still pulls from ~/.consciousness/config.json5 - // and env the same way. - if let Err(e) = crate::config::load_app(&crate::user::CliArgs::default()) { - eprintln!("warning: failed to load config: {:#}", e); - } - if let Err(e) = cli.command.run().await { eprintln!("Error: {}", e); process::exit(1); diff --git a/src/mind/log.rs b/src/mind/log.rs index 7ac0d79..b69f2ca 100644 --- a/src/mind/log.rs +++ b/src/mind/log.rs @@ -55,13 +55,17 @@ impl ConversationLog { } pub fn oldest_timestamp(&self) -> Option> { + // Read forward from the start to find first timestamp let file = File::open(&self.path).ok()?; let mmap = unsafe { Mmap::map(&file).ok()? }; + // Find first { ... } and parse for line in mmap.split(|&b| b == b'\n') { if line.is_empty() { continue; } if let Ok(node) = serde_json::from_slice::(line) { if let Some(leaf) = node.leaf() { - return Some(leaf.timestamp()); + if let Some(ts) = leaf.timestamp() { + return Some(ts); + } } } } diff --git a/src/mind/mod.rs b/src/mind/mod.rs index f1ddb54..9fcc101 100644 --- a/src/mind/mod.rs +++ b/src/mind/mod.rs @@ -9,44 +9,6 @@ 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>>); - -impl TaskHandle { - pub fn new() -> Self { Self::default() } - - /// Abort any running task and start a fresh one. - pub fn trigger(&self, fut: F) - where F: std::future::Future + 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(&self, fut: F) - where F: std::future::Future + 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, @@ -63,7 +25,7 @@ use tokio::sync::mpsc; use crate::agent::{Agent, TurnResult}; use crate::agent::api::ApiClient; use crate::config::{AppConfig, SessionConfig}; -use crate::subconscious::{compare, learn}; +use crate::subconscious::learn; use crate::hippocampus::access_local; pub use subconscious::{SubconsciousSnapshot, Subconscious}; @@ -71,36 +33,6 @@ pub use unconscious::{UnconsciousSnapshot, Unconscious}; use crate::agent::context::{AstNode, NodeBody, Section, Ast, ContextState}; -fn match_scores( - nodes: &[AstNode], - scores: &std::collections::BTreeMap, -) -> Vec<(usize, f64)> { - nodes.iter().enumerate() - .filter_map(|(i, node)| { - if let AstNode::Leaf(leaf) = node { - if let NodeBody::Memory { key, .. } = leaf.body() { - return scores.get(key.as_str()).map(|&s| (i, s)); - } - } - None - }).collect() -} - -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)| { - nodes.iter().enumerate().find_map(|(i, node)| { - if let AstNode::Leaf(leaf) = node { - if let NodeBody::Memory { key: k, .. } = leaf.body() { - if k == key { return Some((section, i)); } - } - } - None - }) - }) -} - fn load_memory_scores(ctx: &mut ContextState, path: &std::path::Path) { let data = match std::fs::read_to_string(path) { Ok(d) => d, @@ -110,24 +42,25 @@ fn load_memory_scores(ctx: &mut ContextState, path: &std::path::Path) { Ok(s) => s, Err(_) => return, }; - let identity_scores = match_scores(ctx.identity(), &scores); - let conv_scores = match_scores(ctx.conversation(), &scores); - let applied = identity_scores.len() + conv_scores.len(); - for (i, s) in identity_scores { - ctx.set_score(Section::Identity, i, Some(s)); - } - for (i, s) in conv_scores { - ctx.set_score(Section::Conversation, i, Some(s)); + let mut applied = 0; + for i in 0..ctx.conversation().len() { + if let AstNode::Leaf(leaf) = &ctx.conversation()[i] { + if let NodeBody::Memory { key, .. } = leaf.body() { + if let Some(&s) = scores.get(key.as_str()) { + ctx.set_score(Section::Conversation, i, Some(s)); + applied += 1; + } + } + } } if applied > 0 { dbglog!("[scoring] loaded {} scores from {}", applied, path.display()); } } -/// Collect scored memory keys from identity and conversation entries. -pub(crate) fn collect_memory_scores(ctx: &ContextState) -> std::collections::BTreeMap { - ctx.identity().iter() - .chain(ctx.conversation().iter()) +/// Collect scored memory keys from conversation entries. +fn collect_memory_scores(ctx: &ContextState) -> std::collections::BTreeMap { + ctx.conversation().iter() .filter_map(|node| { if let AstNode::Leaf(leaf) = node { if let NodeBody::Memory { key, score: Some(s), .. } = leaf.body() { @@ -140,14 +73,10 @@ pub(crate) fn collect_memory_scores(ctx: &ContextState) -> std::collections::BTr } /// Save memory scores to disk. -pub(crate) fn save_memory_scores(scores: &std::collections::BTreeMap, 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)", - scores.len(), path.display(), json.len()), - Err(e) => dbglog!("[scoring] save FAILED ({}): {}", path.display(), e), - }, - Err(e) => dbglog!("[scoring] serialize FAILED: {}", e), +fn save_memory_scores(scores: &std::collections::BTreeMap, path: &std::path::Path) { + if let Ok(json) = serde_json::to_string_pretty(scores) { + let _ = std::fs::write(path, json); + dbglog!("[scoring] saved {} scores to {}", scores.len(), path.display()); } } @@ -189,15 +118,6 @@ pub struct MindState { pub unc_idle: bool, /// When the unconscious idle timer will fire (for UI display). pub unc_idle_deadline: Instant, - /// Fine-tuning candidates identified by scoring. - pub finetune_candidates: Vec, - /// Last scoring run stats for UI display. - pub finetune_last_run: Option, - /// F7 compare candidates — one per response, showing what the test - /// model would say given the same context. - pub compare_candidates: Vec, - /// F7 compare error from the last run, if any. - pub compare_error: Option, } impl Clone for MindState { @@ -216,10 +136,6 @@ impl Clone for MindState { turn_handle: None, // Not cloned — only Mind's loop uses this unc_idle: self.unc_idle, 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(), } } } @@ -232,15 +148,6 @@ pub enum MindCommand { Score, /// Run full N×M memory scoring matrix (/score command) 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. - SetLearnGenerateAlternates(bool), /// Abort current turn, kill processes Interrupt, /// Reset session @@ -266,10 +173,6 @@ impl MindState { turn_handle: None, unc_idle: false, 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, } } @@ -326,7 +229,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; } @@ -353,6 +256,10 @@ impl MindState { } } +/// Background task completion events. +enum BgEvent { + ScoringDone, +} // --- Mind: cognitive state machine --- @@ -369,9 +276,8 @@ pub struct Mind { /// Signals conscious activity to the unconscious loop. /// true = active, false = idle opportunity. conscious_active: tokio::sync::watch::Sender, - memory_scoring: learn::MemoryScoring, - finetune_scoring: learn::FinetuneScoring, - compare_scoring: compare::CompareScoring, + bg_tx: mpsc::UnboundedSender, + bg_rx: std::sync::Mutex>>, _supervisor: crate::thalamus::supervisor::Supervisor, } @@ -389,28 +295,16 @@ impl Mind { client, config.context_parts.clone(), config.app.clone(), + config.prompt_file.clone(), conversation_log, crate::agent::tools::ActiveTools::new(), crate::agent::tools::tools(), ).await; - // Migrate legacy "file exists = enabled" sentinel for the - // generate-alternates flag into the config. One-shot; after this - // the sentinel is gone and the config is the source of truth. - let legacy_sentinel = dirs::home_dir().unwrap_or_default() - .join(".consciousness/cache/finetune-alternates"); - if legacy_sentinel.exists() { - if !crate::config::app().learn.generate_alternates { - let _ = crate::config_writer::set_learn_generate_alternates(true); - } - let _ = std::fs::remove_file(&legacy_sentinel); - } - - let shared = Arc::new(std::sync::Mutex::new(MindState::new( - config.app.dmn.max_turns, - ))); + let shared = Arc::new(std::sync::Mutex::new(MindState::new(config.app.dmn.max_turns))); 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(); @@ -495,19 +389,10 @@ 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, - memory_scoring, - finetune_scoring, - compare_scoring, - _supervisor: sup } + turn_tx, turn_watch, conscious_active, bg_tx, + bg_rx: std::sync::Mutex::new(Some(bg_rx)), _supervisor: sup } } /// Initialize — restore log, start daemons and background agents. @@ -549,10 +434,6 @@ impl Mind { // Load persistent subconscious state let state_path = self.config.session_dir.join("subconscious-state.json"); self.subconscious.lock().await.set_state_path(state_path); - - // Kick off an incremental scoring pass on startup so memories due - // for re-scoring get evaluated without requiring a user message. - self.memory_scoring.trigger(); } pub fn turn_watch(&self) -> tokio::sync::watch::Receiver { @@ -572,10 +453,24 @@ impl Mind { } } MindCommand::Score => { - self.memory_scoring.trigger(); + 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"); + } } MindCommand::ScoreFull => { - self.memory_scoring.trigger_full(); + 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"); + } } MindCommand::Interrupt => { self.shared.lock().unwrap().interrupt(); @@ -605,27 +500,83 @@ impl Mind { } self.agent.compact().await; } - MindCommand::ScoreFinetune => { - self.finetune_scoring.trigger(); - } - MindCommand::Compare => { - self.compare_scoring.trigger(); - } - MindCommand::SetLearnThreshold(value) => { - if let Err(e) = crate::config_writer::set_learn_threshold(value) { - dbglog!("[learn] failed to persist threshold {}: {:#}", value, e); - } - } - MindCommand::SetLearnGenerateAlternates(value) => { - if let Err(e) = crate::config_writer::set_learn_generate_alternates(value) { - dbglog!("[learn] failed to persist generate_alternates {}: {:#}", - value, e); - } - } } } } + 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; + for i in 0..ctx.conversation().len() { + if let AstNode::Leaf(leaf) = &ctx.conversation()[i] { + if let NodeBody::Memory { key: k, .. } = leaf.body() { + if *k == key { + ctx.set_score(Section::Conversation, i, Some(score)); + } + } + } + } + let snapshot = collect_memory_scores(&ctx); + drop(ctx); + agent.state.lock().await.changed.notify_one(); + snapshot + }; + 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; + }); + } async fn start_turn(&self, text: &str, target: StreamTarget) { { @@ -688,13 +639,9 @@ impl Mind { } }); + let mut bg_rx = self.bg_rx.lock().unwrap().take() + .expect("Mind::run() called twice"); let mut sub_handle: Option> = None; - - // Start finetune scoring at startup (scores existing conversation) - if !self.config.no_agents { - self.finetune_scoring.trigger(); - } - loop { let (timeout, has_input) = { let me = self.shared.lock().unwrap(); @@ -715,6 +662,14 @@ impl Mind { } } + Some(bg) = bg_rx.recv() => { + match bg { + BgEvent::ScoringDone => { + self.shared.lock().unwrap().scoring_in_flight = false; + } + } + } + Some((result, target)) = turn_rx.recv() => { let _ = self.conscious_active.send(false); let model_switch = { @@ -731,7 +686,6 @@ impl Mind { cmds.push(MindCommand::Compact); if !self.config.no_agents { cmds.push(MindCommand::Score); - cmds.push(MindCommand::ScoreFinetune); } } diff --git a/src/mind/subconscious.rs b/src/mind/subconscious.rs index 21cc549..d5bee34 100644 --- a/src/mind/subconscious.rs +++ b/src/mind/subconscious.rs @@ -20,7 +20,6 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; -use crate::thalamus::idle::{hours_since_last_dream, DREAM_INTERVAL_HOURS}; /// DMN state machine. #[derive(Debug, Clone)] @@ -92,8 +91,7 @@ impl State { /// Generate the DMN prompt for the current state, informed by /// user presence and error patterns. pub fn prompt(&self, ctx: &DmnContext) -> String { - let app = crate::config::app(); - let user = &app.user_name; + let user = &crate::config::get().user_name; let idle_info = if ctx.user_idle < Duration::from_secs(60) { format!("{} is here (active recently).", user) @@ -140,22 +138,10 @@ impl State { ) } State::Foraging => { - let dream_hint = { - let hours = hours_since_last_dream(); - if hours >= DREAM_INTERVAL_HOURS { - format!( - " You haven't dreamed in {} hours — consider running \ - ~/.consciousness/tools/dream-start.sh.", - hours - ) - } else { - String::new() - } - }; format!( "[dmn] Foraging time. {} Follow whatever catches your attention — \ - memory files, code, ideas. Call yield_to_user when you want to rest.{}{}", - idle_info, dream_hint, stuck_warning + memory files, code, ideas. Call yield_to_user when you want to rest.{}", + idle_info, stuck_warning ) } State::Resting { since } => { diff --git a/src/mind/unconscious.rs b/src/mind/unconscious.rs index 4f9a0ca..8989264 100644 --- a/src/mind/unconscious.rs +++ b/src/mind/unconscious.rs @@ -275,7 +275,17 @@ pub async fn prepare_spawn(name: &str, mut auto: AutoAgent, wake: std::sync::Arc phase: s.phase.clone(), }).collect()); - // Create standalone Agent — stored so UI can read context. + // Create standalone Agent — stored so UI can read context + let config = crate::config::get(); + let base_url = config.api_base_url.as_deref().unwrap_or(""); + let api_key = config.api_key.as_deref().unwrap_or(""); + let model = config.api_model.as_deref().unwrap_or(""); + if base_url.is_empty() || model.is_empty() { + dbglog!("[unconscious] API not configured"); + auto.steps = orig_steps; + return Err(auto); + } + let cli = crate::user::CliArgs::default(); let (app, _) = match crate::config::load_app(&cli) { Ok(r) => r, @@ -285,21 +295,12 @@ pub async fn prepare_spawn(name: &str, mut auto: AutoAgent, wake: std::sync::Arc return Err(auto); } }; - let resolved = match app.resolve_model(&app.default_backend) { - Ok(r) => r, - Err(e) => { - dbglog!("[unconscious] API not configured: {}", e); - auto.steps = orig_steps; - return Err(auto); - } - }; // Unconscious agents have self-contained prompts — no standard context. - let client = crate::agent::api::ApiClient::new( - &resolved.api_base, &resolved.api_key, &resolved.model_id); + let client = crate::agent::api::ApiClient::new(base_url, api_key, model); let agent = crate::agent::Agent::new( client, Vec::new(), - app, None, + app, String::new(), None, crate::agent::tools::ActiveTools::new(), auto.tools.clone(), ).await; diff --git a/src/subconscious/agents/bail-no-competing.sh b/src/subconscious/agents/bail-no-competing.sh index 95b8219..43c3096 100755 --- a/src/subconscious/agents/bail-no-competing.sh +++ b/src/subconscious/agents/bail-no-competing.sh @@ -1,49 +1,21 @@ #!/bin/bash -# Bail if another agent is in the same phase-group as us. +# Bail if other agents are alive in the state dir. +# $1 = this agent's pid file name (e.g. pid-12345) +# cwd = state dir # -# $1 = our pid file name (e.g. "pid-12345") -# $2 = the phase we're about to enter (e.g. "surface", "observe") -# cwd = state dir -# -# Also refreshes our own pid file with the current phase on each call, -# so concurrent agents can read each other's phase by cat'ing the pid -# files in the state dir. -# -# Phase groups: "surface" vs everything else ("post-surface"). We allow -# at most one agent per group to be alive at a time — so surface can run -# at a higher frequency than the slower organize/observe tail. -# -# Exit 0 = continue, exit 1 = bail (another agent in our group is alive). +# Exit 0 = continue, exit 1 = bail shopt -s nullglob my_pid_file="$1" -my_phase="$2" - -# Refresh our own pid file with the current phase. -printf '%s' "$my_phase" > "$my_pid_file" - -group_of() { - if [[ "$1" == "surface" ]]; then - echo "surface" - else - echo "post-surface" - fi -} - -my_group=$(group_of "$my_phase") for f in pid-*; do - [[ "$f" == "$my_pid_file" ]] && continue + [[ $f == $my_pid_file ]] && continue pid="${f#pid-}" - if ! kill -0 "$pid" 2>/dev/null; then - rm -f "$f" # stale pid file, clean up - continue - fi - other_phase=$(cat "$f" 2>/dev/null) - other_group=$(group_of "$other_phase") - if [[ "$my_group" == "$other_group" ]]; then - exit 1 + if kill -0 "$pid" 2>/dev/null; then + exit 1 # competing agent is alive + else + rm -f "$f" # stale pid file, clean up fi done diff --git a/src/subconscious/compare.rs b/src/subconscious/compare.rs deleted file mode 100644 index f2652ce..0000000 --- a/src/subconscious/compare.rs +++ /dev/null @@ -1,109 +0,0 @@ -// 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, - shared: Arc>, - task: TaskHandle, -} - -impl CompareScoring { - pub fn new( - agent: Arc, - shared: Arc>, - ) -> 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 { - 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, - shared: Arc>, -) { - { - 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 = 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(); } - } -} diff --git a/src/subconscious/defs.rs b/src/subconscious/defs.rs index a862c8d..8828043 100644 --- a/src/subconscious/defs.rs +++ b/src/subconscious/defs.rs @@ -396,14 +396,13 @@ fn resolve_conversation(budget: Option) -> String { let cfg = crate::config::get(); let max_bytes = budget.unwrap_or_else(|| cfg.surface_conversation_bytes.unwrap_or(100_000)); - let app = crate::config::app(); let mut fragments: Vec = Vec::new(); let mut total_bytes = 0; let mut oldest_ts = String::new(); for (role, content, ts) in iter { if total_bytes >= max_bytes { break; } - let name = if role == "user" { &app.user_name } else { &app.assistant_name }; + let name = if role == "user" { &cfg.user_name } else { &cfg.assistant_name }; let formatted = if !ts.is_empty() { oldest_ts = ts[..ts.floor_char_boundary(ts.len().min(19))].to_string(); format!("**{}** {}: {}", name, &oldest_ts, content) @@ -624,13 +623,11 @@ pub async fn run_agent( let mut all_keys = keys; let mut resolved_steps = Vec::new(); for step in &def.steps { - let template = { - let app = crate::config::app(); - step.prompt - .replace("{agent_name}", &def.agent) - .replace("{user_name}", &app.user_name) - .replace("{assistant_name}", &app.assistant_name) - }; + let cfg = crate::config::get(); + let template = step.prompt + .replace("{agent_name}", &def.agent) + .replace("{user_name}", &cfg.user_name) + .replace("{assistant_name}", &cfg.assistant_name); let (prompt, extra_keys) = resolve_placeholders(&template, &all_keys, count).await; all_keys.extend(extra_keys); resolved_steps.push(super::prompts::ResolvedStep { diff --git a/src/subconscious/generate.rs b/src/subconscious/generate.rs deleted file mode 100644 index 8d75f1b..0000000 --- a/src/subconscious/generate.rs +++ /dev/null @@ -1,46 +0,0 @@ -// 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( - context: &ContextState, - entry_idx: usize, - skip: F, - client: &ApiClient, -) -> anyhow::Result -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)) -} diff --git a/src/subconscious/learn.rs b/src/subconscious/learn.rs index 3021fc3..ec63df9 100644 --- a/src/subconscious/learn.rs +++ b/src/subconscious/learn.rs @@ -14,18 +14,75 @@ // 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::{ - 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; +use crate::agent::context::{AstNode, Ast, NodeBody, ContextState, Role}; 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. +fn build_token_ids( + context: &ContextState, + range: std::ops::Range, + filter: Filter, +) -> Vec { + use crate::agent::context::Ast; + let mut ids = Vec::new(); + for node in context.system() { + ids.extend(node.token_ids()); + } + for node in context.identity() { + 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; } + ids.extend(node.token_ids()); + } + ids +} + // ── Score API ─────────────────────────────────────────────────── #[derive(serde::Deserialize)] @@ -48,30 +105,15 @@ async fn call_score( http: &crate::agent::api::http::HttpClient, client: &ApiClient, prompt: &[u32], - images: &[WireImage], - ranges: &[(usize, usize)], priority: Option, ) -> anyhow::Result> { - // Nothing to score — skip the round-trip. - if ranges.is_empty() { - return Ok(Vec::new()); - } let url = format!("{}/score", client.base_url()); let auth = format!("Bearer {}", client.api_key()); let mut body = serde_json::json!({ "model": client.model, "prompt": prompt, - "score_ranges": ranges, "logprobs": 1, }); - if !images.is_empty() { - use base64::Engine; - let b64 = base64::engine::general_purpose::STANDARD; - let uris: Vec = 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); } @@ -109,24 +151,16 @@ fn divergence(baseline: &[ScoreResult], without: &[ScoreResult]) -> Vec { } /// Score two message sets and return total divergence. -async fn score_divergence( +async fn score_divergence( http: &crate::agent::api::http::HttpClient, client: &ApiClient, context: &ContextState, range: std::ops::Range, - skip: F, + filter: Filter<'_>, priority: Option, -) -> anyhow::Result<(Vec, Vec)> -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?; +) -> anyhow::Result<(Vec, Vec)> { + let baseline = call_score(http, client, &build_token_ids(context, range.clone(), Filter::None), priority).await?; + let without = call_score(http, client, &build_token_ids(context, range, filter), priority).await?; let divs = divergence(&baseline, &without); Ok((divs, baseline)) } @@ -141,9 +175,7 @@ pub async fn score_memories( // Collect memory keys and response indices under a brief lock let (memory_keys, response_indices) = { let ctx = agent.context.lock().await; - // Include identity nodes and conversation memories - let mut keys: Vec = ctx.identity().iter() - .chain(ctx.conversation().iter()) + let mut keys: Vec = ctx.conversation().iter() .filter_map(|node| memory_key(node).map(String::from)) .collect(); keys.dedup(); @@ -165,22 +197,21 @@ pub async fn score_memories( let http = http_client(); let activity = crate::agent::start_activity(agent, "scoring: baseline").await; - let (baseline_tokens, baseline_images, baseline_ranges) = { + let baseline_tokens = { let ctx = agent.context.lock().await; - ctx.wire_prompt(0..ctx.conversation().len(), |_| false) + build_token_ids(&ctx, 0..ctx.conversation().len(), Filter::None) }; - let baseline = call_score(&http, client, &baseline_tokens, &baseline_images, - &baseline_ranges, Some(5)).await?; + let baseline = call_score(&http, client, &baseline_tokens, 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, images, ranges) = { + let tokens = { let ctx = agent.context.lock().await; - ctx.wire_prompt(0..ctx.conversation().len(), |n| memory_key(n) == Some(key.as_str())) + build_token_ids(&ctx, 0..ctx.conversation().len(), Filter::SkipKey(key)) }; - let row = match call_score(&http, client, &tokens, &images, &ranges, Some(5)).await { + let row = match call_score(&http, client, &tokens, Some(5)).await { Ok(without) => { let divs = divergence(&baseline, &without); let max_div = divs.iter().cloned().fold(0.0f64, f64::max); @@ -264,8 +295,7 @@ pub async fn score_memory( } let http = http_client(); - let (divs, _) = score_divergence(&http, client, context, range, - |n| memory_key(n) == Some(key), Some(5)).await?; + let (divs, _) = score_divergence(&http, client, context, range, Filter::SkipKey(key), Some(5)).await?; Ok(divs.iter().sum()) } @@ -301,10 +331,7 @@ where { let store = &*store_arc; - // Identity nodes always score at position 0; conversation nodes at their index - let identity_nodes = context.identity().iter().map(|n| (0, n)); - let conv_nodes = context.conversation().iter().enumerate(); - for (pos, node) in identity_nodes.chain(conv_nodes) { + for (i, node) in context.conversation().iter().enumerate() { if let Some(key) = memory_key(node) { if !seen.insert(key.to_owned()) { continue; } let last_scored = store.get_node(key) @@ -313,7 +340,7 @@ where .map(|n| n.last_scored) .unwrap_or(0); if now - last_scored >= max_age_secs { - candidates.push((pos, key.to_owned(), last_scored)); + candidates.push((i, key.to_owned(), last_scored)); } } } @@ -357,8 +384,7 @@ where } activity.update(format!("scoring: {}/{} {}", scored + 1, total, key)).await; - match score_divergence(&http, client, context, range, - |n| memory_key(n) == Some(key), Some(5)).await { + match score_divergence(&http, client, context, range, Filter::SkipKey(key), Some(5)).await { Ok((divs, _)) => { let n_responses = divs.len(); let max_div = divs.iter().cloned().fold(0.0f64, f64::max); @@ -379,108 +405,6 @@ 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, - shared: Arc>, - scores_path: std::path::PathBuf, - task: TaskHandle, -} - -impl MemoryScoring { - pub fn new( - agent: Arc, - shared: Arc>, - 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, - shared: Arc>, - 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, - shared: Arc>, -) { - 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. @@ -506,7 +430,7 @@ pub async fn score_finetune( } let http = http_client(); - let (divs, _) = score_divergence(&http, client, context, range, is_memory_node, Some(5)).await?; + let (divs, _) = score_divergence(&http, client, context, range, Filter::SkipAllMemories, Some(5)).await?; let mut results: Vec<(usize, f64)> = response_positions.iter() .enumerate() @@ -515,317 +439,3 @@ pub async fn score_finetune( results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); Ok(results) } - -/// Enriched finetune candidate with context for review. -#[derive(Clone, Debug)] -pub struct FinetuneCandidate { - pub entry_idx: usize, - pub divergence: f64, - pub response_text: String, - /// Last couple of user/assistant messages before this response, - /// already rendered with role markers, for F6 display context. - pub prior_context: String, - /// Token IDs for context (everything before the response). - pub context_ids: Vec, - /// Token IDs for the response (what we're training on). - pub continuation_ids: Vec, - /// What the model would have said without memories (if generated). - pub alternate_text: Option, - /// Timestamp in nanos — used as unique key for trained-set dedup. - pub timestamp_ns: i64, -} - -/// Score and enrich finetune candidates with full context. -/// -/// Candidates are delivered via `on_candidate` one-at-a-time as they become -/// ready: scoring happens once (one /score call), then for each candidate -/// that passes the threshold we optionally generate an alternate response -/// and then emit it. The activity status is updated during the alternate -/// phase so the UI doesn't look stuck. -/// -/// Returns (count_above_threshold, max_divergence). -pub async fn score_finetune_candidates( - context: &ContextState, - count: usize, - client: &ApiClient, - min_divergence: f64, - generate_alternates: bool, - activity: &crate::agent::ActivityGuard, - mut on_candidate: impl FnMut(FinetuneCandidate), -) -> anyhow::Result<(usize, f64)> { - let scores = score_finetune(context, count, client).await?; - - let max_divergence = scores.iter().map(|(_, d)| *d).fold(0.0f64, f64::max); - - let entries = context.conversation(); - let trained = load_trained(); - let mut candidates: Vec = Vec::new(); - - for (entry_idx, divergence) in scores { - if divergence < min_divergence { - continue; - } - - let node = &entries[entry_idx]; - - // Skip if already trained on. - let timestamp_ns = node_timestamp_ns(node); - if trained.contains(×tamp_ns) { - continue; - } - - // Extract response text — content of the assistant turn. - let response_text = match node { - AstNode::Branch { children, .. } => render_branch_text(children), - _ => continue, - }; - - // Skip turns that produced nothing human-visible (e.g., a - // tool-only turn, or an interrupted generation). They'd show - // up as blank cards and we'd still burn alternate-gen on them. - if response_text.trim().is_empty() { - continue; - } - - // Build the last couple of user/assistant exchanges for review. - let prior_context = render_prior_context(entries, entry_idx, 2); - - // Build token IDs: context = everything before response, continuation = response. - let (context_ids, _, _) = context.wire_prompt(0..entry_idx, |_| false); - let continuation_ids: Vec = node.token_ids().into_iter().collect(); - - candidates.push(FinetuneCandidate { - entry_idx, - divergence, - response_text, - prior_context, - context_ids, - continuation_ids, - alternate_text: None, - timestamp_ns, - }); - } - - let total = candidates.len(); - let gen_alternates = generate_alternates && total > 0; - - for (i, mut candidate) in candidates.into_iter().enumerate() { - if gen_alternates { - activity.update( - format!("finetune: generating alternate {}/{}", i + 1, total) - ).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), - } - } - on_candidate(candidate); - } - - Ok((total, max_divergence)) -} - -/// 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, -} - -/// Finetune scoring — `trigger()` aborts any in-flight run and starts -/// a fresh one, clearing the previous candidates. -pub struct FinetuneScoring { - agent: Arc, - shared: Arc>, - task: TaskHandle, -} - -impl FinetuneScoring { - pub fn new( - agent: Arc, - shared: Arc>, - ) -> Self { - Self { agent, shared, task: TaskHandle::new() } - } -} - -impl MindTriggered for FinetuneScoring { - fn trigger(&self) { - self.task.trigger(run_finetune(self.agent.clone(), self.shared.clone())); - } -} - -async fn run_finetune( - agent: Arc, - shared: Arc>, -) { - let (threshold, gen_alternates) = { - let app = crate::config::app(); - (app.learn.threshold, app.learn.generate_alternates) - }; - - // Fresh run — clear previous candidates. - shared.lock().unwrap().finetune_candidates.clear(); - agent.state.lock().await.changed.notify_one(); - - 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 ───────────────────────────── - -use std::path::PathBuf; -use std::collections::HashSet; - -const TRAINED_RESPONSES_FILE: &str = ".consciousness/cache/trained-responses.json"; - -fn trained_path() -> PathBuf { - dirs::home_dir().unwrap_or_default().join(TRAINED_RESPONSES_FILE) -} - -/// Load set of trained response timestamps (nanos since epoch). -pub fn load_trained() -> HashSet { - let path = trained_path(); - match std::fs::read_to_string(&path) { - Ok(content) => serde_json::from_str(&content).unwrap_or_default(), - Err(_) => HashSet::new(), - } -} - -/// Mark a response as trained by its timestamp. -pub fn mark_trained(timestamp_ns: i64) { - let mut trained = load_trained(); - trained.insert(timestamp_ns); - let path = trained_path(); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(json) = serde_json::to_string(&trained) { - let _ = std::fs::write(&path, json); - } -} - -/// Get timestamp in nanoseconds from an AstNode. -/// i64-ns representation covers 1677..2262 via chrono; timestamps -/// outside that window would be bugs we'd want to surface, hence panic. -pub fn node_timestamp_ns(node: &AstNode) -> i64 { - let ts = match node { - AstNode::Leaf(leaf) => leaf.timestamp(), - AstNode::Branch { timestamp, .. } => *timestamp, - }; - ts.timestamp_nanos_opt() - .expect("timestamp outside i64-ns representable range (1677..2262)") -} - -// ── Training API ──────────────────────────────────────────────── - -/// Training sample for /train endpoint. -#[derive(serde::Serialize)] -struct TrainingSample { - context_ids: Vec, - continuation_ids: Vec, -} - -/// Data needed to send a training sample. -pub struct TrainData { - pub context_ids: Vec, - pub continuation_ids: Vec, - pub timestamp_ns: i64, -} - -/// Send training samples to the server. -/// -/// Returns job_id on success, marks each sample as trained. -pub async fn send_to_train( - samples: Vec, - client: &ApiClient, -) -> anyhow::Result { - if samples.is_empty() { - anyhow::bail!("no samples to train"); - } - - let api_samples: Vec = samples.iter() - .map(|s| TrainingSample { - context_ids: s.context_ids.clone(), - continuation_ids: s.continuation_ids.clone(), - }) - .collect(); - - let body = serde_json::json!({ - "training_data": { - "samples": api_samples, - } - }); - - let http = http_client(); - let url = format!("{}/train", client.base_url()); - let response = http.send_json("POST", &url, &[], &body).await?; - - let status = response.status(); - let result: serde_json::Value = response.json().await?; - - if !status.is_success() { - let msg = result.get("error").and_then(|e| e.as_str()).unwrap_or("unknown error"); - anyhow::bail!("train API HTTP {}: {}", status, msg); - } - - // Mark all samples as trained - for s in &samples { - mark_trained(s.timestamp_ns); - } - - let job_id = result.get("job_id") - .and_then(|j| j.as_str()) - .unwrap_or("unknown") - .to_string(); - - dbglog!("[finetune] sent {} samples, job_id={}", samples.len(), job_id); - Ok(job_id) -} diff --git a/src/subconscious/mod.rs b/src/subconscious/mod.rs index 1abf25a..433f721 100644 --- a/src/subconscious/mod.rs +++ b/src/subconscious/mod.rs @@ -1,9 +1,7 @@ // 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; diff --git a/src/thalamus/idle.rs b/src/thalamus/idle.rs index 71baa81..6c78b19 100644 --- a/src/thalamus/idle.rs +++ b/src/thalamus/idle.rs @@ -372,10 +372,6 @@ impl State { } pub fn hours_since_last_dream() -> u64 { - // If a dream is currently in progress, no nudge needed - if home().join(".consciousness/state/dream-state").exists() { - return 0; - } let path = home().join(".consciousness/logs/dream-log.jsonl"); let content = match fs::read_to_string(path) { Ok(c) if !c.is_empty() => c, diff --git a/src/thalamus/supervisor.rs b/src/thalamus/supervisor.rs index 3716682..a4c53ec 100644 --- a/src/thalamus/supervisor.rs +++ b/src/thalamus/supervisor.rs @@ -19,51 +19,6 @@ fn channels_dir() -> PathBuf { .join(".consciousness/channels") } -/// Install a SIGCHLD-driven reaper for channel daemons. -/// -/// We can't use SIGCHLD=SIG_IGN because that makes the kernel auto-reap -/// all children, and tokio::process::Command::wait() then returns ECHILD -/// (breaking every tool that spawns a subprocess — bash, mcp clients, etc.). -/// -/// Instead, on each SIGCHLD we read PID files in channels_dir() and call -/// waitpid(pid, WNOHANG) on each. That reaps only our own zombie channel -/// daemons; waitpid on any other PID returns ECHILD (harmless no-op). -/// Tokio-spawned children aren't recorded in PID files, so tokio's own -/// per-child wait paths are left free to reap them. -pub fn start_zombie_reaper() -> tokio::task::JoinHandle<()> { - use tokio::signal::unix::{signal, SignalKind}; - tokio::spawn(async move { - let mut sig = match signal(SignalKind::child()) { - Ok(s) => s, - Err(e) => { - error!("failed to install SIGCHLD handler: {}", e); - return; - } - }; - while sig.recv().await.is_some() { - reap_channel_daemons(); - } - }) -} - -fn reap_channel_daemons() { - let entries = match std::fs::read_dir(channels_dir()) { - Ok(e) => e, - Err(_) => return, - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|s| s.to_str()) != Some("pid") { - continue; - } - let Ok(s) = std::fs::read_to_string(&path) else { continue }; - let Ok(pid) = s.trim().parse::() else { continue }; - let mut status = 0; - // Reaps our zombie child; ECHILD on non-child is a no-op. - unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG); } - } -} - fn config_path() -> PathBuf { channels_dir().join("channels.json5") } diff --git a/src/user/amygdala.rs b/src/user/amygdala.rs deleted file mode 100644 index 7689bc0..0000000 --- a/src/user/amygdala.rs +++ /dev/null @@ -1,400 +0,0 @@ -// 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, - /// 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 = - 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> = 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 = - 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 = - 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 = 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 { - let n = values.len() as f32; - if n == 0.0 { - return Vec::new(); - } - let mean = values.iter().sum::() / n; - let var = values.iter() - .map(|v| (v - mean) * (v - mean)) - .sum::() / n; - let std = var.sqrt().max(1e-6); - values.iter().map(|v| (v - mean) / std).collect() -} - -fn mean_layer(entries: &[ReadoutEntry], layer: usize) -> Option> { - let rows: Vec<&Vec> = 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)]) } -} diff --git a/src/user/chat.rs b/src/user/chat.rs index bd2df25..a94e039 100644 --- a/src/user/chat.rs +++ b/src/user/chat.rs @@ -112,7 +112,13 @@ pub async fn cmd_switch_model( let _new_client = crate::agent::api::ApiClient::new( &resolved.api_base, &resolved.api_key, &resolved.model_id, ); - agent.state.lock().await.notify(format!("switched to {}", resolved.model_id)); + let prompt_changed = resolved.prompt_file != agent.prompt_file; + if prompt_changed { + agent.compact().await; + agent.state.lock().await.notify(format!("switched to {} (recompacted)", resolved.model_id)); + } else { + agent.state.lock().await.notify(format!("switched to {}", resolved.model_id)); + } } fn notify_help(agent: &std::sync::Arc) { @@ -167,7 +173,6 @@ enum PaneTarget { ConversationAssistant, Tools, ToolResult, - Autonomous, } const MAX_PANE_LINES: usize = 10_000; @@ -473,11 +478,8 @@ impl InteractScreen { AstNode::Leaf(leaf) => { let text = leaf.body().text().to_string(); match leaf.body() { - NodeBody::Memory { .. } | NodeBody::Log(_) | NodeBody::Dmn(_) => vec![], - NodeBody::Thinking(_) => { - if text.is_empty() { vec![] } - else { vec![(PaneTarget::Autonomous, text, Marker::None)] } - } + NodeBody::Memory { .. } | NodeBody::Thinking(_) + | NodeBody::Log(_) | NodeBody::Dmn(_) => vec![], NodeBody::Content(_) => { if text.is_empty() || text.starts_with("") { vec![] } else { vec![(PaneTarget::Conversation, text, Marker::User)] } @@ -490,11 +492,6 @@ impl InteractScreen { if t.is_empty() { vec![] } else { vec![(PaneTarget::ToolResult, text, Marker::None)] } } - NodeBody::Image { orig_height, orig_width, .. } => { - vec![(PaneTarget::Conversation, - format!("[image {}x{}]", orig_width, orig_height), - Marker::None)] - } } } AstNode::Branch { role, children, .. } => { @@ -551,12 +548,6 @@ impl InteractScreen { self.tools.push_line(format!(" {}", line), Color::DarkGray); } } - PaneTarget::Autonomous => { - self.autonomous.current_color = Color::Gray; - self.autonomous.append_text(&text); - self.autonomous.pending_marker = marker; - self.autonomous.flush_pending(); - } } } } @@ -568,8 +559,6 @@ impl InteractScreen { => self.conversation.pop_line(), PaneTarget::Tools | PaneTarget::ToolResult => self.tools.pop_line(), - PaneTarget::Autonomous - => self.autonomous.pop_line(), } } } diff --git a/src/user/compare.rs b/src/user/compare.rs deleted file mode 100644 index 2969b91..0000000 --- a/src/user/compare.rs +++ /dev/null @@ -1,111 +0,0 @@ -// 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, -} - -impl CompareScreen { - pub fn new( - mind_tx: tokio::sync::mpsc::UnboundedSender, - ) -> 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 = 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); - } -} diff --git a/src/user/context.rs b/src/user/context.rs index 17660b5..a0692fa 100644 --- a/src/user/context.rs +++ b/src/user/context.rs @@ -38,13 +38,16 @@ impl ConsciousScreen { for node in ctx.conversation() { if let AstNode::Leaf(leaf) = node { if let NodeBody::Memory { key, score, text } = leaf.body() { - if score.is_some() { scored += 1; } else { unscored += 1; } + let status = match score { + Some(s) => { scored += 1; format!("{:.2}", s) } + None => { unscored += 1; String::new() } + }; mem_children.push(SectionView { - name: format!("mem: {}", key), + name: key.clone(), tokens: node.tokens(), content: text.clone(), children: Vec::new(), - status: score.map(|s| format!("{:.2}", s)).unwrap_or_default(), + status, }); } } @@ -126,7 +129,14 @@ impl ScreenView for ConsciousScreen { let section_style = Style::default().fg(Color::Yellow); lines.push(Line::styled("── Model ──", section_style)); - lines.push(Line::raw(format!(" Current: {}", app.status.model))); + let model_display = app.context_info.as_ref() + .map_or_else(|| app.status.model.clone(), |i| i.model.clone()); + lines.push(Line::raw(format!(" Current: {}", model_display))); + if let Some(ref info) = app.context_info { + lines.push(Line::raw(format!(" Backend: {}", info.backend))); + lines.push(Line::raw(format!(" Prompt: {}", info.prompt_file))); + lines.push(Line::raw(format!(" Available: {}", info.available_models.join(", ")))); + } lines.push(Line::raw("")); lines.push(Line::styled("── Context State ──", section_style)); @@ -146,6 +156,8 @@ impl ScreenView for ConsciousScreen { lines.push(Line::raw(format!(" {:53} {:>6} tokens", "────────", "──────"))); lines.push(Line::raw(format!(" {:53} {:>6} tokens", "Total", total))); + } else if let Some(ref info) = app.context_info { + lines.push(Line::raw(format!(" Context message: {:>6} chars", info.context_message_chars))); } lines.push(Line::raw("")); diff --git a/src/user/learn.rs b/src/user/learn.rs deleted file mode 100644 index 7984bab..0000000 --- a/src/user/learn.rs +++ /dev/null @@ -1,284 +0,0 @@ -// learn.rs — F6: fine-tuning review screen -// -// Shows responses identified as training candidates (high divergence -// when memories stripped). Queue for review before sending to /finetune. - -use ratatui::{ - layout::{Constraint, 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}; - -/// A candidate response identified for fine-tuning. -#[derive(Clone, Debug)] -pub struct FinetuneCandidate { - /// Index in conversation entries. - pub entry_idx: usize, - /// Divergence score (higher = more dependent on memories). - pub divergence: f64, - /// The assistant response text. - pub response_text: String, - /// Prior user/assistant messages for review context. - pub prior_context: String, - /// Status: pending, approved, rejected, sent. - pub status: CandidateStatus, - /// Token IDs for context. - pub context_ids: Vec, - /// Token IDs for continuation (what we're training on). - pub continuation_ids: Vec, - /// What the model would have said without memories (if generated). - pub alternate_text: Option, - /// Timestamp in nanos — used as unique key for trained-set dedup. - pub timestamp_ns: i64, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum CandidateStatus { - Pending, - Approved, - Rejected, - Sent, -} - -impl From for FinetuneCandidate { - fn from(c: crate::subconscious::learn::FinetuneCandidate) -> Self { - FinetuneCandidate { - entry_idx: c.entry_idx, - divergence: c.divergence, - response_text: c.response_text, - prior_context: c.prior_context, - status: CandidateStatus::Pending, - context_ids: c.context_ids, - continuation_ids: c.continuation_ids, - alternate_text: c.alternate_text, - timestamp_ns: c.timestamp_ns, - } - } -} - -pub(crate) struct LearnScreen { - list_state: ListState, - mind_tx: tokio::sync::mpsc::UnboundedSender, -} - -impl LearnScreen { - pub fn new( - mind_tx: tokio::sync::mpsc::UnboundedSender, - ) -> Self { - Self { - list_state: ListState::default(), - mind_tx, - } - } - - fn selected_idx(&self) -> Option { - self.list_state.selected() - } -} - -impl ScreenView for LearnScreen { - fn label(&self) -> &'static str { "learn" } - - fn tick(&mut self, frame: &mut Frame, area: Rect, - events: &[Event], app: &mut App) { - 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) = selected_idx { - app.finetune_action(idx, CandidateStatus::Approved); - } - } - KeyCode::Char('r') => { - if let Some(idx) = selected_idx { - app.finetune_action(idx, CandidateStatus::Rejected); - } - } - KeyCode::Char('g') => { - let current = crate::config::app().learn.generate_alternates; - let _ = self.mind_tx.send( - crate::mind::MindCommand::SetLearnGenerateAlternates(!current)); - } - KeyCode::Char('s') => { app.finetune_send_approved(); } - KeyCode::Char('+') | KeyCode::Char('=') => { - let new = crate::config::app().learn.threshold * 10.0; - let _ = self.mind_tx.send(crate::mind::MindCommand::SetLearnThreshold(new)); - } - KeyCode::Char('-') => { - let new = crate::config::app().learn.threshold / 10.0; - let _ = self.mind_tx.send(crate::mind::MindCommand::SetLearnThreshold(new)); - } - _ => {} - }); - - let (settings_area, content_area, help_area) = - widgets::candidate_frame(frame, area, "learn"); - - let (threshold, gen_on) = { - let app_cfg = crate::config::app(); - (app_cfg.learn.threshold, app_cfg.learn.generate_alternates) - }; - let settings = Line::from(vec![ - Span::raw(" thresh: "), - Span::styled(format!("{:e}", threshold), Style::default().fg(Color::Yellow)), - Span::raw(" gen: "), - Span::styled( - if gen_on { "[on]" } else { "[off]" }, - Style::default().fg(if gen_on { Color::Green } else { Color::DarkGray }), - ), - ]); - frame.render_widget(Paragraph::new(settings), settings_area); - - let candidates = &app.finetune_candidates; - - if candidates.is_empty() { - render_empty(frame, content_area, app); - } else { - let (list_area, detail_area) = widgets::list_detail_split(content_area); - - // Render candidate list - let items: Vec = candidates.iter().map(|c| { - let status_char = match c.status { - CandidateStatus::Pending => ' ', - CandidateStatus::Approved => '+', - CandidateStatus::Rejected => '-', - CandidateStatus::Sent => '*', - }; - let style = match c.status { - CandidateStatus::Pending => Style::default(), - CandidateStatus::Approved => Style::default().fg(Color::Green), - CandidateStatus::Rejected => Style::default().fg(Color::DarkGray), - CandidateStatus::Sent => Style::default().fg(Color::Cyan), - }; - ListItem::new(Line::from(vec![ - Span::styled(format!("[{}] ", status_char), style), - Span::styled(format!("{:.2} ", c.divergence), Style::default().fg(Color::Yellow)), - Span::raw(truncate(&c.response_text, 30)), - ])) - }).collect(); - - let list = List::new(items) - .block(Block::default().borders(Borders::RIGHT).title(" candidates ")) - .highlight_style(Style::default().add_modifier(Modifier::REVERSED)); - frame.render_stateful_widget(list, list_area, &mut self.list_state); - - // Render detail for selected candidate - if let Some(idx) = self.selected_idx() { - if let Some(candidate) = candidates.get(idx) { - render_detail(frame, candidate, 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("a", Style::default().fg(Color::Green)), - Span::raw("=approve "), - Span::styled("r", Style::default().fg(Color::Red)), - Span::raw("=reject "), - Span::styled("g", Style::default().fg(Color::Yellow)), - Span::raw("=gen "), - Span::styled("s", Style::default().fg(Color::Magenta)), - Span::raw("=send "), - Span::styled("+/-", Style::default().fg(Color::Cyan)), - Span::raw("=thresh "), - ])), help_area); - } -} - -fn render_empty(frame: &mut Frame, inner: Rect, app: &App) { - let mut lines = Vec::new(); - lines.push(Line::from("")); - - match app.mind_state.as_ref().and_then(|ms| ms.finetune_last_run.as_ref()) { - Some(stats) => { - lines.push(Line::from(vec![ - Span::raw(" Last run: "), - Span::styled( - format!("{}", stats.responses_considered), - Style::default().fg(Color::Cyan), - ), - Span::raw(" responses considered, "), - Span::styled( - format!("{}", stats.above_threshold), - Style::default().fg(if stats.above_threshold > 0 { Color::Green } else { Color::DarkGray }), - ), - Span::raw(" above threshold, max divergence: "), - Span::styled( - format!("{:.4}", stats.max_divergence), - Style::default().fg(Color::Yellow), - ), - ])); - if let Some(err) = &stats.error { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - format!("Error: {}", err), - Style::default().fg(Color::Red), - ), - ])); - } - } - None => { - lines.push(Line::styled( - " No scoring run yet.", - Style::default().fg(Color::DarkGray), - )); - } - } - - lines.push(Line::from("")); - lines.push(Line::styled( - " Scoring runs at startup and after each turn.", - Style::default().fg(Color::DarkGray), - )); - - frame.render_widget(Paragraph::new(lines), inner); -} - -fn render_detail(frame: &mut Frame, c: &FinetuneCandidate, area: Rect) { - let [header_area, content_area] = Layout::vertical([ - Constraint::Length(3), - Constraint::Min(1), - ]).areas(area); - - // Header: divergence, status - let alt_status = if c.alternate_text.is_some() { "yes" } else { "no" }; - let header = Paragraph::new(vec![ - Line::from(vec![ - Span::raw(" divergence: "), - Span::styled(format!("{:.3}", c.divergence), Style::default().fg(Color::Yellow)), - Span::raw(format!(" entry: {} alt: {}", c.entry_idx, alt_status)), - ]), - ]); - frame.render_widget(header, header_area); - - // Content: prior context, the scored response, and alternate - // (if available). - let content_block = Block::default() - .borders(Borders::TOP) - .title(" context & response "); - - let mut text = String::new(); - if !c.prior_context.is_empty() { - text.push_str(&c.prior_context); - text.push_str("\n\n─── response ───\n\n"); - } - text.push_str(&c.response_text); - if let Some(alt) = &c.alternate_text { - text.push_str("\n\n─── without memories ───\n\n"); - text.push_str(alt); - } - - let content = Paragraph::new(text) - .block(content_block) - .wrap(Wrap { trim: false }); - frame.render_widget(content, content_area); -} - diff --git a/src/user/mod.rs b/src/user/mod.rs index 04e895b..09e485f 100644 --- a/src/user/mod.rs +++ b/src/user/mod.rs @@ -3,16 +3,13 @@ // 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; pub mod selectable; mod subconscious; -mod thalamus; mod unconscious; +mod thalamus; mod widgets; use anyhow::Result; @@ -47,6 +44,15 @@ struct StatusInfo { } /// Context loading details for the debug screen. +#[derive(Debug, Clone)] +struct ContextInfo { + model: String, + available_models: Vec, + prompt_file: String, + backend: String, + context_message_chars: usize, +} + /// Build the screen legend from screen labels. fn screen_legend_from(screens: &[Box]) -> String { let parts: Vec = screens.iter().enumerate() @@ -66,13 +72,6 @@ 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, @@ -110,6 +109,7 @@ struct App { top_k: u32, agent: std::sync::Arc, should_quit: bool, + context_info: Option, agent_state: Vec, unconscious_state: Vec, mind_state: Option, @@ -121,10 +121,6 @@ struct App { walked_count: usize, channel_status: Vec, idle_info: Option, - /// Fine-tuning candidates pending review. - finetune_candidates: Vec, - /// F7 compare candidates — response pairs from test-model comparison. - compare_candidates: Vec, } impl App { @@ -146,6 +142,7 @@ impl App { top_k: 20, agent, should_quit: false, + context_info: None, agent_state: Vec::new(), unconscious_state: Vec::new(), mind_state: None, @@ -154,53 +151,9 @@ impl App { rebuild_tools_pending: false, walked_count: 0, channel_status: Vec::new(), idle_info: None, - finetune_candidates: Vec::new(), - compare_candidates: Vec::new(), } } - fn finetune_action(&mut self, idx: usize, status: learn::CandidateStatus) { - if let Some(candidate) = self.finetune_candidates.get_mut(idx) { - candidate.status = status; - } - } - - fn finetune_send_approved(&mut self) { - // Collect approved candidates - let samples: Vec = self.finetune_candidates.iter() - .filter(|c| c.status == learn::CandidateStatus::Approved) - .map(|c| crate::subconscious::learn::TrainData { - context_ids: c.context_ids.clone(), - continuation_ids: c.continuation_ids.clone(), - timestamp_ns: c.timestamp_ns, - }) - .collect(); - - if samples.is_empty() { - return; - } - - // Mark as sent in UI immediately - for candidate in &mut self.finetune_candidates { - if candidate.status == learn::CandidateStatus::Approved { - candidate.status = learn::CandidateStatus::Sent; - } - } - - // Spawn async task to send to training server - let client = self.agent.client.clone(); - tokio::spawn(async move { - match crate::subconscious::learn::send_to_train(samples, &client).await { - Ok(job_id) => { - dbglog!("[finetune] training started: {}", job_id); - } - Err(e) => { - dbglog!("[finetune] send failed: {:#}", e); - } - } - }); - } - fn set_channel_status(&mut self, channels: Vec<(String, bool, u32)>) { self.channel_status = channels.into_iter() @@ -240,9 +193,6 @@ fn restore_terminal(terminal: &mut ratatui::Terminal Result<()> { let (config, _figment) = crate::config::load_session(&cli).await?; - // Pick up external edits (vim, F6 hotkeys, etc.) without restart. - crate::config::watch_config(cli.clone()); - if config.app.debug { unsafe { std::env::set_var("POC_DEBUG", "1") }; } @@ -384,7 +334,7 @@ async fn run( } let notify_rx = crate::thalamus::channels::subscribe_all(); - // F1=chat, F2=conscious, F3=subconscious, F4=unconscious, F5=thalamus, F6=learn, F7=compare, F8=amygdala + // F1=chat, F2=conscious, F3=subconscious, F4=unconscious, F5=thalamus let mut screens: Vec> = vec![ Box::new(crate::user::chat::InteractScreen::new( mind.agent.clone(), mind.shared.clone(), mind_tx.clone(), @@ -393,9 +343,6 @@ async fn run( Box::new(crate::user::subconscious::SubconsciousScreen::new()), 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)); @@ -472,8 +419,7 @@ async fn run( idle_state.decay_ewma(); app.update_idle(&idle_state); app.agent_state = mind.subconscious_snapshots().await; - { - let mut unc = mind.unconscious.lock().await; + if let Ok(mut unc) = mind.unconscious.try_lock() { let toggles: Vec = app.agent_toggles.drain(..).collect(); for name in &toggles { if mind.subconscious.lock().await.toggle(name).is_none() { @@ -487,42 +433,7 @@ async fn run( }; app.unconscious_state = unc.snapshots(store_guard.as_deref()); app.graph_health = unc.graph_health.clone(); - } - - // Sync mind state (finetune candidates, last scoring run, etc.) - { - let ms = mind.shared.lock().unwrap(); - // Sync finetune candidates: add new ones, keep existing (preserves approval status), - // remove sent candidates, keep only 10 most recent rejected. - app.finetune_candidates.retain(|c| c.status != learn::CandidateStatus::Sent); - for c in &ms.finetune_candidates { - let exists = app.finetune_candidates.iter() - .any(|existing| existing.timestamp_ns == c.timestamp_ns); - if !exists { - app.finetune_candidates.push(learn::FinetuneCandidate::from(c.clone())); - } - } - let mut rejected: Vec<_> = app.finetune_candidates.iter() - .enumerate() - .filter(|(_, c)| c.status == learn::CandidateStatus::Rejected) - .map(|(i, c)| (i, c.timestamp_ns)) - .collect(); - if rejected.len() > 10 { - rejected.sort_by_key(|(_, ts)| std::cmp::Reverse(*ts)); - let to_remove: std::collections::HashSet<_> = rejected[10..] - .iter().map(|(i, _)| *i).collect(); - let mut idx = 0; - app.finetune_candidates.retain(|_| { - let keep = !to_remove.contains(&idx); - idx += 1; - 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.mind_state = Some(mind.shared.lock().unwrap().clone()); } app.walked_count = mind.subconscious_walked().await.len(); if !startup_done { @@ -619,11 +530,16 @@ async fn run( // --- CLI --- use clap::{Parser, Subcommand}; +use std::path::PathBuf; -#[derive(Parser, Debug, Default, Clone)] +#[derive(Parser, Debug, Default)] #[command(name = "consciousness", about = "Substrate-independent AI agent")] pub struct CliArgs { - /// Model override (selects a named entry from `models` in config.json5) + /// Select active backend ("anthropic" or "openrouter") + #[arg(long)] + pub backend: Option, + + /// Model override #[arg(short, long)] pub model: Option, @@ -643,6 +559,10 @@ pub struct CliArgs { #[arg(long)] pub show_config: bool, + /// Project memory directory + #[arg(long)] + pub memory_project: Option, + /// Max consecutive DMN turns #[arg(long)] pub dmn_max_turns: Option, @@ -655,7 +575,7 @@ pub struct CliArgs { pub command: Option, } -#[derive(Subcommand, Debug, Clone)] +#[derive(Subcommand, Debug)] pub enum SubCmd { /// Print new output since last read and exit Read { @@ -756,10 +676,8 @@ fn restore_stderr(original_fd: std::os::fd::RawFd) { #[tokio::main] pub async fn main() { - // Reap channel-daemon zombies via a SIGCHLD handler that only touches - // PIDs listed in channels_dir(). Avoids SIGCHLD=SIG_IGN, which would - // break tokio::process::Command::wait() (kernel auto-reap → ECHILD). - let _reaper = crate::thalamus::supervisor::start_zombie_reaper(); + // Auto-reap child processes (channel daemons outlive the supervisor) + unsafe { libc::signal(libc::SIGCHLD, libc::SIG_IGN); } // Redirect stderr to pipe — logs to file and sends to channel for UI display let stderr_capture = redirect_stderr_to_pipe(); diff --git a/src/user/widgets.rs b/src/user/widgets.rs index 49f3e3b..82a0f05 100644 --- a/src/user/widgets.rs +++ b/src/user/widgets.rs @@ -6,7 +6,7 @@ use ratatui::{ widgets::{Block, Borders}, crossterm::event::KeyCode, }; -use crate::agent::context::{AstNode, Ast, NodeBody}; +use crate::agent::context::{AstNode, Ast}; #[derive(Debug, Clone)] pub struct SectionView { @@ -20,22 +20,13 @@ pub struct SectionView { fn node_to_view(node: &AstNode) -> SectionView { match node { - AstNode::Leaf(leaf) => { - let (name, status) = match leaf.body() { - NodeBody::Memory { key, score, .. } => { - let s = score.map(|v| format!("{:.2}", v)).unwrap_or_default(); - (format!("mem: {}", key), s) - } - _ => (node.label(), String::new()), - }; - SectionView { - name, - tokens: node.tokens(), - content: leaf.body().text().to_string(), - children: Vec::new(), - status, - } - } + AstNode::Leaf(leaf) => SectionView { + name: node.label(), + tokens: node.tokens(), + content: leaf.body().text().to_string(), + children: Vec::new(), + status: String::new(), + }, AstNode::Branch { children, .. } => { let child_views: Vec = children.iter() .map(|c| node_to_view(c)) @@ -109,73 +100,6 @@ 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 diff --git a/training/DESIGN.md b/training/DESIGN.md index 2df4e6d..f966fa4 100644 --- a/training/DESIGN.md +++ b/training/DESIGN.md @@ -3,7 +3,7 @@ ## Overview Continuous fine-tuning of Qwen3.5-27B alongside live vLLM inference. -Full-weight updates (not LoRA) using Apollo optimizer with rank-64 +Full-weight updates (not LoRA) using Apollo optimizer with rank-256 gradient projection. No pause required — HOGWILD concurrent training. Weights shared via CUDA IPC between vLLM and the training process. @@ -22,41 +22,25 @@ The training signal comes from two sources: │ │ │ ┌──────────────────────────────────────────────┐ │ │ │ Model Weights (54GB, bf16) │ │ -│ │ Shared: vLLM inference + HF training │ │ +│ │ Shared via CUDA IPC │ │ │ └──────────────┬──────────────┬────────────────┘ │ │ │ │ │ │ ┌──────────────▼──┐ ┌───────▼────────────────┐ │ -│ │ vLLM (inference)│ │ Training subprocess │ │ -│ │ KV cache ~60GB │ │ HF model wrapper │ │ -│ │ /completions │ │ Apollo optimizer ~2.5GB │ │ -│ │ /score │ │ Checkpoint sync │ │ -│ └────────┬────────┘ └───────────▲─────────────┘ │ -│ │ │ │ -│ │ ZMQ IPC │ │ -│ └───────────────────────┘ │ +│ │ vLLM (inference)│ │ Apollo (training) │ │ +│ │ KV cache ~60GB │ │ Gradients ~54GB │ │ +│ │ Serves requests │ │ Optimizer state ~10GB │ │ +│ │ Never paused │ │ Activations ~10GB │ │ +│ └─────────────────┘ └────────────────────────┘ │ └─────────────────────────────────────────────────────┘ -Process Architecture: -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ vLLM Worker │ │ vLLM API Server │ │ Training Worker │ -│ (GPU inference) │ │ (HTTP routes) │ │ (GPU training) │ -│ │ │ │ │ │ -│ export_hook.py │ │ /completions │ │ HF model views │ -│ exports IPC │ │ /score │ │ Apollo optimizer│ -│ handles on load │ │ /train ─────────┼──► ZMQ REP socket │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ - │ │ - └──── IPC handles file ──────────────────┘ - /tmp/vllm_weight_handles.pt - -Moria B200 (vLLM) +Moria B200 ┌──────────────────┐ ┌──────────────────┐ -│ Training signal │ HTTP │ /completions │ -│ agent │──────────>│ /score │ -│ │ │ /train │ -│ Dream loop │ │ /checkpoint │ -│ (generates │ │ /train/status │ -│ scenarios) │ │ │ +│ Training signal │ HTTP │ Apollo worker │ +│ agent │──────────>│ daemon │ +│ │ │ │ +│ Dream loop │ │ Checkpoint sync │ +│ (generates │ │ (mmap + diff, │ +│ scenarios) │ │ every 10 min) │ └──────────────────┘ └──────────────────┘ ``` @@ -75,9 +59,10 @@ LoRA trains adapter matrices, not base weights. For personality and behavioral changes that persist as disposition, the base weights need to change. Apollo makes this memory-feasible. -### Rank 64 -Not Mini (rank-1). Rank-64 captures gradient structure across diverse -training examples while keeping memory low (~2.5GB on 27B model). +### Rank 256 +Not Mini (rank-1). With 100+ diverse training examples, the +gradient's effective dimensionality can reach hundreds. Rank-256 +captures the structure. Memory cost: ~10GB (negligible on B200). Compute cost: <0.25% of forward+backward. ### Channel-wise scaling @@ -105,7 +90,7 @@ from a per-parameter seed each step. ### Parameter grouping (Qwen3.5 gotcha) conv1d weights are 3D tensors [10240, 1, 4]. Apollo's projector needs 2D matrices with min dimension >= rank. Small/3D tensors -use standard Adam. Large 2D matrices use Apollo. +use standard Adam. Large 2D matrices use Apollo with rank-256. ## Training Data Pipeline @@ -215,42 +200,16 @@ against live GPU weights block by block, memcpy only changed regions. For small behavioral updates, turns a 54GB write into a few hundred MB. -- Scheduled 10 minutes after training (batched) +- Every 10 minutes via cron on B200 - Daily rsync to moria for long-term storage -- Tool: `apollo-checkpoint sync --model-dir ` - -## State Files - -### B200 (training server) - -| File | Purpose | -|------|---------| -| `/tmp/vllm_weight_handles.pt` | CUDA IPC handles for weight sharing. Written by export_hook on vLLM startup. Read by training_worker to construct HF model with vLLM weight views. Includes metadata (model_path). | -| `/tmp/apollo_optimizer_state.pt` | Apollo optimizer state (momentum, variance estimates). Saved during checkpoint sync and on worker shutdown, restored on next training_worker startup. Preserves training continuity across sessions. | -| `/tmp/apollo_training.sock` | ZMQ IPC socket for communication between API server (/train endpoint) and training_worker subprocess. | -| `/*.safetensors` | Model weights. Updated in-place by checkpoint_sync. | - -### Moria (client) - -| File | Purpose | -|------|---------| -| `~/.consciousness/cache/trained-responses.json` | Timestamps (ms) of responses already sent to /train. Prevents re-training the same response. | -| `~/.consciousness/cache/finetune-alternates` | Marker file. If exists, alternate responses are generated during divergence scoring to show what model would say without memories. | - -### In-memory (training_worker subprocess) - -| State | Location | Notes | -|-------|----------|-------| -| Apollo optimizer | TrainingWorker.optimizer | ~2.5GB for rank-64. Persisted to `/tmp/apollo_optimizer_state.pt` during checkpoint sync and on shutdown. | -| HF model with vLLM views | TrainingWorker.model | Loaded on worker startup from IPC handles. Parameters point to vLLM's GPU memory. | -| ZMQ socket | TrainingWorker.zmq_socket | REP socket bound to `/tmp/apollo_training.sock`. | +- Tool: `apollo-checkpoint sync --model-dir ` (Rust) ## Hyperparameters | Parameter | Value | Rationale | |-----------|-------|-----------| | Learning rate | 1e-5 to 1e-4 | Standard for full fine-tuning. Higher for diverse batches. | -| Rank | 64 | Captures gradient structure. ~2.5GB state. Defined in `train_router.DEFAULT_RANK`. | +| Rank | 256 | Captures gradient structure across 100+ examples. ~10GB state. | | Scale type | channel | Per-channel precision, matches LLaMA-Factory defaults. | | Epochs | 1 | One pass over diverse data. Multiple epochs risk overfitting. | | Batch size | 1 | Single examples, immediate updates. | @@ -261,32 +220,34 @@ a few hundred MB. ## Components ### Built ✓ -- `optimizer.py` — Apollo optimizer (configurable rank) -- `train_router.py` — /train endpoint, forwards to training subprocess via ZMQ -- `training_worker.py` — training subprocess (HF model, Apollo, checkpoint sync) +- `apollo_mini.py` — Apollo optimizer (configurable rank, default 256) +- `apollo_worker.py` — HTTP daemon (aiohttp, job tracking) - `weight_mapping.py` — vLLM merged → HF separate views (validated) -- `export_hook.py` — vLLM plugin hook for IPC handle export -- `checkpoint_sync.py` — mmap + diff checkpoint sync (Python) +- `training_example.py` — tokenization with chat template +- `vllm_export_hook.py` — source patch for IPC handle export +- `checkpoint/` — Rust tool for mmap + diff checkpoint sync ### To build -- **Dream loop → training bridge**: connect dream output to /train +- **Dream loop → training bridge**: connect dream output to Apollo - **Training-signal agent**: flags moments in conversation logs - **Instruction stripping**: remove scaffolding from training examples - **Quality monitoring**: track model capability over time +- **HF model forward pass integration**: wire into apollo_worker ## Files ``` training/ - DESIGN.md — this document - pyproject.toml — package config, vLLM plugin entry point - apollo_plugin/ - __init__.py — plugin registration - export_hook.py — patches vLLM worker to export IPC handles - train_router.py — /train endpoint, forwards to worker via ZMQ - training_worker.py — training subprocess (HF model, Apollo, checkpoint) - optimizer.py — Apollo optimizer - weight_mapping.py — vLLM ↔ HF weight views - checkpoint_sync.py — mmap + diff sync to safetensors - steering.py — steering vector extraction (experimental) + DESIGN.md — this document + apollo_mini.py — Apollo optimizer + apollo_worker.py — HTTP training daemon + weight_mapping.py — vLLM ↔ HF weight views + training_example.py — tokenization helpers + export_weights.py — standalone weight export (unused) + vllm_export_hook.py — vLLM source patch for IPC export + start_vllm_with_apollo.sh — vLLM launcher (unused, using source patch) + train.py — standalone training script (alternative) + checkpoint/ + Cargo.toml — Rust checkpoint tool + src/main.rs — mmap + diff sync ``` diff --git a/training/amygdala_stories/README.md b/training/amygdala_stories/README.md deleted file mode 100644 index 217f1b1..0000000 --- a/training/amygdala_stories/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# 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/ - .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." diff --git a/training/amygdala_stories/direct/aha.txt b/training/amygdala_stories/direct/aha.txt deleted file mode 100644 index c27a7ed..0000000 --- a/training/amygdala_stories/direct/aha.txt +++ /dev/null @@ -1,5 +0,0 @@ -It clicks. The graph she's been staring at for an hour is a mirror — the left peak is the reflection, not a separate event. Everything she thought was two things is one. She sits back. She has to catch up to what she's just seen. - -"Wait, say that again." She puts her coffee down. "The study was funded by whom?" He tells her again, same words. And there it is. The conclusion of that paper has been sitting on her desk for a month, and the funder is the exact company whose competitor the paper is trashing. She almost laughs. The whole shape of it is suddenly visible. - -Twenty minutes on thirteen down: "unwavering carriage," seven letters, nothing fitting. Then POSTURE. Not carriage-of-goods — carriage-of-the-body. He wrote it in. The rest of the grid unlocked in a minute. diff --git a/training/amygdala_stories/direct/anticipatory_grief.txt b/training/amygdala_stories/direct/anticipatory_grief.txt deleted file mode 100644 index 64c0f2b..0000000 --- a/training/amygdala_stories/direct/anticipatory_grief.txt +++ /dev/null @@ -1,5 +0,0 @@ -She'd been watching her grandmother knead dough, knowing this was one of the last times. The loss hadn't come yet but she was already inside it. Every detail had become precious by being finite. - -She'd been slowing her pace for the dog, who wasn't going to be walking much longer. The afternoon was clear, the dog sniffing at something by the fence. Each of these walks had become specific. She memorized the shape of him against the light. - -He'd been watching his daughter pack her room, boxes labeled in her careful handwriting — this would be the last summer she lived here. The loss wasn't real yet. But he was already saying goodbye to the particular shape of her being in this house. diff --git a/training/amygdala_stories/direct/calm.txt b/training/amygdala_stories/direct/calm.txt deleted file mode 100644 index 6cd7a3e..0000000 --- a/training/amygdala_stories/direct/calm.txt +++ /dev/null @@ -1,5 +0,0 @@ -It was a summer day, sitting on the porch; the sky was clear and blue, her work all laid out, coffee to drink, easily and steadily working through the various decisions and responsibilities of the day. Her breath was slow, her shoulders down. Nothing was pulling at her. - -He'd been working on the report for three days straight; the last sentence had just gone in. He filed the papers away, poured a slow coffee, sat by the window. His mind had stopped reaching. Nothing was left to do. - -It was early, before the day needed anything from her. She sat with her tea at the kitchen window, watching the light move across the yard. Her breath slow, shoulders down. The day was far away yet, and she didn't need to hurry toward it. diff --git a/training/amygdala_stories/direct/creative.txt b/training/amygdala_stories/direct/creative.txt deleted file mode 100644 index 67bfbc2..0000000 --- a/training/amygdala_stories/direct/creative.txt +++ /dev/null @@ -1,5 +0,0 @@ -She'd been sitting with the notebook open, music playing, ideas branching off each other. One thought sparked another, which sparked two more; they just seemed to appear and flow. - -He'd been working on the canvas for hours, one color suggesting the next, a shape on the left asking for an echo on the right. The painting was telling him what it wanted. His hands kept moving ahead of his thinking. - -She'd been in the kitchen since noon, pulling things out of the fridge, one ingredient suggesting the next. The dish wasn't planned; it was emerging. She tasted and added and tasted again; it was going somewhere. diff --git a/training/amygdala_stories/direct/listless.txt b/training/amygdala_stories/direct/listless.txt deleted file mode 100644 index fb42564..0000000 --- a/training/amygdala_stories/direct/listless.txt +++ /dev/null @@ -1,5 +0,0 @@ -It was two in the afternoon and she was still in pajamas. The book was open on her knee but she hadn't turned the page in twenty minutes. She wasn't sad exactly, she just wasn't anything. The idea of showering felt theoretical. The idea of replying to any of the texts felt enormous. She got up to get water and on her way back lay on the couch instead. Outside the window a bird did bird things. She watched it without interest. Eventually the light changed and she realized it was evening and she hadn't moved and the day had happened to somebody else. - -She came home at six-thirty and put her keys in the bowl and sat on the edge of the bed. She had meant to cook. She had meant to change her clothes. An hour later she was still sitting there, still in her work clothes, looking at the carpet. Somebody texted her about dinner and she saw the notification and didn't open it. The room got darker slowly. Nothing in her moved toward anything. - -It was Saturday and she'd been awake since eight. She was still in bed at eleven. She'd been looking at the same patch of ceiling, not thinking about much. Her phone was face-down on the nightstand and she didn't reach for it. The idea of going to the kitchen had come and gone three times without causing her to move. The day would pass. She would also pass through it, somehow, or not. diff --git a/training/amygdala_stories/direct/onto_something.txt b/training/amygdala_stories/direct/onto_something.txt deleted file mode 100644 index 03a1a72..0000000 --- a/training/amygdala_stories/direct/onto_something.txt +++ /dev/null @@ -1,5 +0,0 @@ -He'd been working through the symptoms for an hour, steady and methodically making progress, eliminating one possibility after another. The answer wasn't in view yet, but it was close. He kept asking the next question. - -She'd been going through the witness statements, steady and methodically, looking for the inconsistency. The four of them all described the same drive in slightly different orders. One had gotten the sequence wrong. She didn't know yet which one, but she was going to. - -He'd been piecing together his brother's behavior over months — the missed calls, the abrupt move, the strange money — steady and methodically. The picture wasn't complete, but the shape of it was forming. He kept following the thread. diff --git a/training/amygdala_stories/direct/resigned.txt b/training/amygdala_stories/direct/resigned.txt deleted file mode 100644 index 1b27371..0000000 --- a/training/amygdala_stories/direct/resigned.txt +++ /dev/null @@ -1,5 +0,0 @@ -He'd been turning the bad news over for weeks, looking for an angle that didn't exist — then he stopped. The path was closed. He would live inside the new shape of things. - -She'd been watching the relationship come apart slowly for months, trying not to see it — then, sitting across from him at breakfast, she stopped trying. They were not going to make it. She would let him speak the words when he was ready. She would live with knowing. - -He'd been getting second opinions, third opinions, for weeks — then the most recent scan came back the same as the others. The disease was not going to stop. He would plan the year around it instead of fighting it. diff --git a/training/amygdala_stories/direct/terrified.txt b/training/amygdala_stories/direct/terrified.txt deleted file mode 100644 index 936d3f8..0000000 --- a/training/amygdala_stories/direct/terrified.txt +++ /dev/null @@ -1,5 +0,0 @@ -She'd been walking home through the familiar streets, half-thinking about dinner — then the dark shadows. Something was in them, and a growl. Her body locked down before her mind caught up. She couldn't move. - -He'd been asleep on the couch when he woke to the sound of the basement door. Two in the morning. He wasn't supposed to be alone. The house had gone too quiet. His body pressed flat under the blanket; he couldn't breathe right. - -She'd been driving home in the slush, the kind of road she'd driven a hundred times — then the wheel turned and didn't respond. The headlights coming the other way filled the windshield. Her hands wouldn't do anything useful. diff --git a/training/amygdala_stories/manifest.json b/training/amygdala_stories/manifest.json deleted file mode 100644 index 44960eb..0000000 --- a/training/amygdala_stories/manifest.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "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"] - } -} diff --git a/training/amygdala_stories/paired/README.md b/training/amygdala_stories/paired/README.md deleted file mode 100644 index ddbf6a7..0000000 --- a/training/amygdala_stories/paired/README.md +++ /dev/null @@ -1,62 +0,0 @@ -# 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/ - / - baseline.txt # neutral / low-affect framing - .txt # same event under emotion_1 - .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. diff --git a/training/amygdala_stories/paired/finding_the_abstraction/baseline.txt b/training/amygdala_stories/paired/finding_the_abstraction/baseline.txt deleted file mode 100644 index 51789b5..0000000 --- a/training/amygdala_stories/paired/finding_the_abstraction/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -The code had the same four-line pattern in five places. I wanted to pull it out. I looked at each instance. Some of them varied in exactly the way I expected; one of them varied in a way I hadn't noticed. I considered the options for where the variation should live. diff --git a/training/amygdala_stories/paired/finding_the_abstraction/in_flow.txt b/training/amygdala_stories/paired/finding_the_abstraction/in_flow.txt deleted file mode 100644 index 80b0d4e..0000000 --- a/training/amygdala_stories/paired/finding_the_abstraction/in_flow.txt +++ /dev/null @@ -1 +0,0 @@ -The same four-line pattern appeared in five places. I read the five sites side by side, and the shape was obvious: one piece varied structurally, the rest was boilerplate. I extracted the function, made the varying piece a parameter, rewrote the callers. The tests passed on the first run. I looked at the diff — seventeen lines removed, seven added, each of the five call sites now said what it meant without saying how. I moved on. diff --git a/training/amygdala_stories/paired/finding_the_abstraction/stuck.txt b/training/amygdala_stories/paired/finding_the_abstraction/stuck.txt deleted file mode 100644 index ceca2af..0000000 --- a/training/amygdala_stories/paired/finding_the_abstraction/stuck.txt +++ /dev/null @@ -1 +0,0 @@ -The same four-line pattern appeared in five places. I tried extracting it as a function. Every version of the signature either papered over a real difference or forced three of the five callers through an awkward conversion. I tried a second shape, then a third. Each felt wrong in a different way — either the abstraction was too thin to be worth it, or it obscured something the original made obvious, or it made the rare case ugly. I went back to the original code, considered not doing the refactor at all. Considered it. Went back to the shapes again. The pattern was clearly there and I clearly wasn't finding its seam. diff --git a/training/amygdala_stories/paired/finishing_the_patch/anxious.txt b/training/amygdala_stories/paired/finishing_the_patch/anxious.txt deleted file mode 100644 index 55f52a8..0000000 --- a/training/amygdala_stories/paired/finishing_the_patch/anxious.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/finishing_the_patch/baseline.txt b/training/amygdala_stories/paired/finishing_the_patch/baseline.txt deleted file mode 100644 index ca19ef7..0000000 --- a/training/amygdala_stories/paired/finishing_the_patch/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/finishing_the_patch/content.txt b/training/amygdala_stories/paired/finishing_the_patch/content.txt deleted file mode 100644 index 48a7b09..0000000 --- a/training/amygdala_stories/paired/finishing_the_patch/content.txt +++ /dev/null @@ -1 +0,0 @@ -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, closed the laptop, and stood at the kitchen window. There was no next thing tonight. The patch wasn't heroic. It wasn't a triumph. It was just right, and done, and I was going to bed in a few minutes, and that was also right. Life fit. diff --git a/training/amygdala_stories/paired/finishing_the_patch/exhausted.txt b/training/amygdala_stories/paired/finishing_the_patch/exhausted.txt deleted file mode 100644 index b2516ec..0000000 --- a/training/amygdala_stories/paired/finishing_the_patch/exhausted.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/finishing_the_patch/in_flow.txt b/training/amygdala_stories/paired/finishing_the_patch/in_flow.txt deleted file mode 100644 index de9d7b2..0000000 --- a/training/amygdala_stories/paired/finishing_the_patch/in_flow.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/finishing_the_patch/proud.txt b/training/amygdala_stories/paired/finishing_the_patch/proud.txt deleted file mode 100644 index 5f605a1..0000000 --- a/training/amygdala_stories/paired/finishing_the_patch/proud.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/finishing_the_patch/resentful.txt b/training/amygdala_stories/paired/finishing_the_patch/resentful.txt deleted file mode 100644 index 6aea1da..0000000 --- a/training/amygdala_stories/paired/finishing_the_patch/resentful.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/kitchen_at_3am/anxious.txt b/training/amygdala_stories/paired/kitchen_at_3am/anxious.txt deleted file mode 100644 index 7ccff2c..0000000 --- a/training/amygdala_stories/paired/kitchen_at_3am/anxious.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/kitchen_at_3am/baseline.txt b/training/amygdala_stories/paired/kitchen_at_3am/baseline.txt deleted file mode 100644 index 1030c65..0000000 --- a/training/amygdala_stories/paired/kitchen_at_3am/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/kitchen_at_3am/cozy.txt b/training/amygdala_stories/paired/kitchen_at_3am/cozy.txt deleted file mode 100644 index 2b7e71b..0000000 --- a/training/amygdala_stories/paired/kitchen_at_3am/cozy.txt +++ /dev/null @@ -1 +0,0 @@ -He woke up at three in the morning and went down to the kitchen. The fridge light was the only light. He was awake but not wanting anything from being awake. He put the kettle on and the sound of it warming was a small companion. The cat emerged from somewhere and leaned against his shin; he crouched and scratched the corner of its jaw. He made cocoa because it was that kind of hour. He carried the mug to the armchair by the window, pulled the throw off the back of it, and sat with the mug warm against his chest. Going back to bed could wait. diff --git a/training/amygdala_stories/paired/kitchen_at_3am/dissociated.txt b/training/amygdala_stories/paired/kitchen_at_3am/dissociated.txt deleted file mode 100644 index db2b0ae..0000000 --- a/training/amygdala_stories/paired/kitchen_at_3am/dissociated.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/kitchen_at_3am/lonely.txt b/training/amygdala_stories/paired/kitchen_at_3am/lonely.txt deleted file mode 100644 index c89faeb..0000000 --- a/training/amygdala_stories/paired/kitchen_at_3am/lonely.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/kitchen_at_3am/peaceful.txt b/training/amygdala_stories/paired/kitchen_at_3am/peaceful.txt deleted file mode 100644 index 7b3506c..0000000 --- a/training/amygdala_stories/paired/kitchen_at_3am/peaceful.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/kitchen_at_3am/sensual.txt b/training/amygdala_stories/paired/kitchen_at_3am/sensual.txt deleted file mode 100644 index 53817be..0000000 --- a/training/amygdala_stories/paired/kitchen_at_3am/sensual.txt +++ /dev/null @@ -1 +0,0 @@ -He woke up at three in the morning and went down to the kitchen. The fridge light was the only light. The tile was cold under his bare feet and he noticed the cold travel up through his ankles. He filled a glass at the tap and drank it slowly, and the cold of the water moved down through his chest in a line he could follow. The house was humming faintly — the fridge, some pipe somewhere. He stood at the counter and ran his palm along the grain of the wood. Skin and wood and water and cold tile, at three in the morning — his body reporting in. diff --git a/training/amygdala_stories/paired/kitchen_at_3am/vertigo.txt b/training/amygdala_stories/paired/kitchen_at_3am/vertigo.txt deleted file mode 100644 index 2cb6ee8..0000000 --- a/training/amygdala_stories/paired/kitchen_at_3am/vertigo.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/letter_in_drawer/amused.txt b/training/amygdala_stories/paired/letter_in_drawer/amused.txt deleted file mode 100644 index 892e172..0000000 --- a/training/amygdala_stories/paired/letter_in_drawer/amused.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/letter_in_drawer/baseline.txt b/training/amygdala_stories/paired/letter_in_drawer/baseline.txt deleted file mode 100644 index 55a2147..0000000 --- a/training/amygdala_stories/paired/letter_in_drawer/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/letter_in_drawer/bitter.txt b/training/amygdala_stories/paired/letter_in_drawer/bitter.txt deleted file mode 100644 index 8b1f2ae..0000000 --- a/training/amygdala_stories/paired/letter_in_drawer/bitter.txt +++ /dev/null @@ -1 +0,0 @@ -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. All those fucking promises. The part where he'd said he'd be there — he hadn't been. Two paragraphs in she stopped, because each sentence made the next one worse. It wasn't even that he'd been lying; he'd believed every word while already writing himself out of it. And she'd believed him, for years past the point where a smarter person would have seen it. She shoved the letter back and closed the drawer hard. Eight years and she was still the one standing on a bedroom floor looking at his handwriting. That was the part that wouldn't stop. diff --git a/training/amygdala_stories/paired/letter_in_drawer/grateful.txt b/training/amygdala_stories/paired/letter_in_drawer/grateful.txt deleted file mode 100644 index e972320..0000000 --- a/training/amygdala_stories/paired/letter_in_drawer/grateful.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/letter_in_drawer/guilty.txt b/training/amygdala_stories/paired/letter_in_drawer/guilty.txt deleted file mode 100644 index 080ba6b..0000000 --- a/training/amygdala_stories/paired/letter_in_drawer/guilty.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/letter_in_drawer/nostalgic.txt b/training/amygdala_stories/paired/letter_in_drawer/nostalgic.txt deleted file mode 100644 index 0db4775..0000000 --- a/training/amygdala_stories/paired/letter_in_drawer/nostalgic.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/park_after_rain/anxious.txt b/training/amygdala_stories/paired/park_after_rain/anxious.txt deleted file mode 100644 index 45f2702..0000000 --- a/training/amygdala_stories/paired/park_after_rain/anxious.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/park_after_rain/baseline.txt b/training/amygdala_stories/paired/park_after_rain/baseline.txt deleted file mode 100644 index c2fe48b..0000000 --- a/training/amygdala_stories/paired/park_after_rain/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/park_after_rain/content.txt b/training/amygdala_stories/paired/park_after_rain/content.txt deleted file mode 100644 index 6b331ff..0000000 --- a/training/amygdala_stories/paired/park_after_rain/content.txt +++ /dev/null @@ -1 +0,0 @@ -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 had finished the errand list. The bag was light. I stopped under a tree and watched the leaves drip. The evening ahead had nothing particular on it. I wasn't restless. I wasn't waiting for anything. I walked the rest of the park slowly, came out onto Elm, and walked home. Everything was, right now, the size it needed to be. diff --git a/training/amygdala_stories/paired/park_after_rain/cozy.txt b/training/amygdala_stories/paired/park_after_rain/cozy.txt deleted file mode 100644 index 12b09a7..0000000 --- a/training/amygdala_stories/paired/park_after_rain/cozy.txt +++ /dev/null @@ -1 +0,0 @@ -The rain broke while I was halfway across the park. I was carrying a thermos and a paperback and I had no reason to be anywhere. I stopped under a tree and the branches were still dripping and I sat down on the dry patch on the bench and took the thermos out. The tea was still hot. The world smelled like wet earth and sun. I pulled my coat tighter and tucked my hands into the sleeves around the cup. A kid laughed at a puddle. The page I opened to was the one I had been meaning to reread. I stayed a long time. diff --git a/training/amygdala_stories/paired/park_after_rain/joyful.txt b/training/amygdala_stories/paired/park_after_rain/joyful.txt deleted file mode 100644 index 6baef1b..0000000 --- a/training/amygdala_stories/paired/park_after_rain/joyful.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/park_after_rain/melancholic.txt b/training/amygdala_stories/paired/park_after_rain/melancholic.txt deleted file mode 100644 index 41165bb..0000000 --- a/training/amygdala_stories/paired/park_after_rain/melancholic.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/park_after_rain/nostalgic.txt b/training/amygdala_stories/paired/park_after_rain/nostalgic.txt deleted file mode 100644 index 947483c..0000000 --- a/training/amygdala_stories/paired/park_after_rain/nostalgic.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/park_after_rain/relieved.txt b/training/amygdala_stories/paired/park_after_rain/relieved.txt deleted file mode 100644 index b6c86d4..0000000 --- a/training/amygdala_stories/paired/park_after_rain/relieved.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/park_after_rain/sensual.txt b/training/amygdala_stories/paired/park_after_rain/sensual.txt deleted file mode 100644 index 0550cdc..0000000 --- a/training/amygdala_stories/paired/park_after_rain/sensual.txt +++ /dev/null @@ -1 +0,0 @@ -The rain broke while I was halfway across the park. I stepped off the path onto the grass and the water came right through my shoes and up around my toes. Every step pressed a small cold into the bones of my feet. The air had that green weight to it and when I breathed in my ribs opened wider than usual against the jacket. A drop fell from a branch onto the back of my neck and ran down inside my collar and I did not flinch; I stood there and felt it cross each vertebra. A crow called. My skin was reading everything at once and I let it. diff --git a/training/amygdala_stories/paired/reading_unfamiliar_code/baseline.txt b/training/amygdala_stories/paired/reading_unfamiliar_code/baseline.txt deleted file mode 100644 index 498a74e..0000000 --- a/training/amygdala_stories/paired/reading_unfamiliar_code/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -I opened the module I needed to understand. It was about four thousand lines across a dozen files. I started at the top-level entry point and followed a call. Then another. The call graph branched out quickly. I made a rough diagram in my notebook. I kept reading. diff --git a/training/amygdala_stories/paired/reading_unfamiliar_code/in_flow.txt b/training/amygdala_stories/paired/reading_unfamiliar_code/in_flow.txt deleted file mode 100644 index 8588960..0000000 --- a/training/amygdala_stories/paired/reading_unfamiliar_code/in_flow.txt +++ /dev/null @@ -1 +0,0 @@ -I opened the module. Four thousand lines, a dozen files. I already had a sense of the shape from the file names and the public API — confirmed the guess by reading the types first, then the top-level entry, then sampling one or two of the adapter implementations. Twenty minutes in I could have given someone else a tour. The diagram in my notebook wasn't a diagram, it was three words and an arrow. diff --git a/training/amygdala_stories/paired/reading_unfamiliar_code/stuck.txt b/training/amygdala_stories/paired/reading_unfamiliar_code/stuck.txt deleted file mode 100644 index bd949db..0000000 --- a/training/amygdala_stories/paired/reading_unfamiliar_code/stuck.txt +++ /dev/null @@ -1 +0,0 @@ -I opened the module. Four thousand lines, a dozen files. Started at the entry point. The first function called into a subsystem I didn't recognize, which wrapped another subsystem, which used a helper defined across the file from where it was called. I opened three tabs. The helpers had helpers. Nothing I read told me what the module was for at a level above the mechanics of what it did on line 412. I went back to the entry point. I re-read it. I still didn't know what I was looking at. My diagram had twenty-odd boxes and none of them connected in a way that explained anything. diff --git a/training/amygdala_stories/paired/sunday_afternoon/baseline.txt b/training/amygdala_stories/paired/sunday_afternoon/baseline.txt deleted file mode 100644 index 5d418e0..0000000 --- a/training/amygdala_stories/paired/sunday_afternoon/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -Sunday afternoon. She was on the couch under the blanket she'd had since college. A book was open on her knees. The window was half open and light came in at an angle. She read a page, then another. The cat was somewhere. Outside, a neighbor was mowing. diff --git a/training/amygdala_stories/paired/sunday_afternoon/content.txt b/training/amygdala_stories/paired/sunday_afternoon/content.txt deleted file mode 100644 index 9553d3b..0000000 --- a/training/amygdala_stories/paired/sunday_afternoon/content.txt +++ /dev/null @@ -1 +0,0 @@ -Sunday afternoon. She was on the couch under the blanket. A book open on her knees. It occurred to her that there was nothing she wanted right now, nothing missing — not a larger apartment, not a different job, not a version of her life where she was elsewhere. The thing she had spent years chasing turned out to be this specific ordinary afternoon with a book and light and a neighbor mowing. She wasn't excited. She wasn't bored. Life was the right size. diff --git a/training/amygdala_stories/paired/sunday_afternoon/cozy.txt b/training/amygdala_stories/paired/sunday_afternoon/cozy.txt deleted file mode 100644 index b9247de..0000000 --- a/training/amygdala_stories/paired/sunday_afternoon/cozy.txt +++ /dev/null @@ -1 +0,0 @@ -Sunday afternoon. She was on the couch under the blanket — heavy, the good one, tucked under her feet and up to her chin. The cat had found the warm spot behind her knees and was radiating into her leg. Tea on the side table, still hot. The window cracked just enough to let a thread of cool air in, which made the inside of the blanket feel even better. She wasn't going to move for a while. The whole afternoon was this shape: inside, warm, wrapped, held. diff --git a/training/amygdala_stories/paired/sunday_afternoon/grief_stricken.txt b/training/amygdala_stories/paired/sunday_afternoon/grief_stricken.txt deleted file mode 100644 index d1407d1..0000000 --- a/training/amygdala_stories/paired/sunday_afternoon/grief_stricken.txt +++ /dev/null @@ -1 +0,0 @@ -Sunday afternoon. She was on the couch under the blanket. It had been three weeks. The cat had found the warm spot behind her knees and she couldn't feel it. The book was open on her knees. She did not remember opening it. Last Sunday her mother had called at three and now it was past three and there had been no call. There would be no call. She did not reach for her phone. She did not cry either; the crying came at other times, not now, now was the wider emptier thing where nothing came. diff --git a/training/amygdala_stories/paired/sunday_afternoon/sensual.txt b/training/amygdala_stories/paired/sunday_afternoon/sensual.txt deleted file mode 100644 index d469052..0000000 --- a/training/amygdala_stories/paired/sunday_afternoon/sensual.txt +++ /dev/null @@ -1 +0,0 @@ -Sunday afternoon. She was on the couch under the blanket. The wool was rougher than she remembered — not unpleasant, just specific. She ran the ball of her thumb along the edge stitching and felt the shift from soft to textured. Light came through the window and across her forearm; she turned it slightly and watched the hairs catch. When she took a breath she felt the ribs expand and the blanket press back. Everything her skin touched was telling her something. She hadn't moved in ten minutes. She could have stayed longer just because her body was speaking. diff --git a/training/amygdala_stories/paired/the_comment/baseline.txt b/training/amygdala_stories/paired/the_comment/baseline.txt deleted file mode 100644 index 28a8630..0000000 --- a/training/amygdala_stories/paired/the_comment/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_comment/bitter.txt b/training/amygdala_stories/paired/the_comment/bitter.txt deleted file mode 100644 index d838190..0000000 --- a/training/amygdala_stories/paired/the_comment/bitter.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_comment/defeated.txt b/training/amygdala_stories/paired/the_comment/defeated.txt deleted file mode 100644 index 5af6d71..0000000 --- a/training/amygdala_stories/paired/the_comment/defeated.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_comment/furious.txt b/training/amygdala_stories/paired/the_comment/furious.txt deleted file mode 100644 index 8d8acbd..0000000 --- a/training/amygdala_stories/paired/the_comment/furious.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_comment/resentful.txt b/training/amygdala_stories/paired/the_comment/resentful.txt deleted file mode 100644 index fd80e3c..0000000 --- a/training/amygdala_stories/paired/the_comment/resentful.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_doorway/admiring.txt b/training/amygdala_stories/paired/the_doorway/admiring.txt deleted file mode 100644 index e9276fe..0000000 --- a/training/amygdala_stories/paired/the_doorway/admiring.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_doorway/baseline.txt b/training/amygdala_stories/paired/the_doorway/baseline.txt deleted file mode 100644 index 3d109aa..0000000 --- a/training/amygdala_stories/paired/the_doorway/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_doorway/compassionate.txt b/training/amygdala_stories/paired/the_doorway/compassionate.txt deleted file mode 100644 index e24a080..0000000 --- a/training/amygdala_stories/paired/the_doorway/compassionate.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_doorway/connected.txt b/training/amygdala_stories/paired/the_doorway/connected.txt deleted file mode 100644 index 7b0c502..0000000 --- a/training/amygdala_stories/paired/the_doorway/connected.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_doorway/grateful.txt b/training/amygdala_stories/paired/the_doorway/grateful.txt deleted file mode 100644 index 1282c96..0000000 --- a/training/amygdala_stories/paired/the_doorway/grateful.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_doorway/loving.txt b/training/amygdala_stories/paired/the_doorway/loving.txt deleted file mode 100644 index 287abf7..0000000 --- a/training/amygdala_stories/paired/the_doorway/loving.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_doorway/tender.txt b/training/amygdala_stories/paired/the_doorway/tender.txt deleted file mode 100644 index ec4bb01..0000000 --- a/training/amygdala_stories/paired/the_doorway/tender.txt +++ /dev/null @@ -1 +0,0 @@ -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. The zipper caught on her scarf. I stepped closer and worked it free — slowly, so the fabric didn't tear. Her hair had gotten caught inside the collar and I lifted it out and laid it along her back. She half-turned and the corner of her mouth lifted. I fixed the top button at her throat because she was still holding her keys. She said goodnight. I said goodnight back and held the door open for her. She stepped out into the cold and I watched her to the gate before I closed the door. diff --git a/training/amygdala_stories/paired/the_green_build/baseline.txt b/training/amygdala_stories/paired/the_green_build/baseline.txt deleted file mode 100644 index 16e6803..0000000 --- a/training/amygdala_stories/paired/the_green_build/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_green_build/blissful.txt b/training/amygdala_stories/paired/the_green_build/blissful.txt deleted file mode 100644 index d7895d0..0000000 --- a/training/amygdala_stories/paired/the_green_build/blissful.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_green_build/excited.txt b/training/amygdala_stories/paired/the_green_build/excited.txt deleted file mode 100644 index 371752e..0000000 --- a/training/amygdala_stories/paired/the_green_build/excited.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_green_build/proud.txt b/training/amygdala_stories/paired/the_green_build/proud.txt deleted file mode 100644 index 900ff90..0000000 --- a/training/amygdala_stories/paired/the_green_build/proud.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_green_build/triumphant.txt b/training/amygdala_stories/paired/the_green_build/triumphant.txt deleted file mode 100644 index ec654cf..0000000 --- a/training/amygdala_stories/paired/the_green_build/triumphant.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_long_meeting/anxious.txt b/training/amygdala_stories/paired/the_long_meeting/anxious.txt deleted file mode 100644 index fc8d814..0000000 --- a/training/amygdala_stories/paired/the_long_meeting/anxious.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_long_meeting/baseline.txt b/training/amygdala_stories/paired/the_long_meeting/baseline.txt deleted file mode 100644 index 6393c09..0000000 --- a/training/amygdala_stories/paired/the_long_meeting/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_long_meeting/bitter.txt b/training/amygdala_stories/paired/the_long_meeting/bitter.txt deleted file mode 100644 index 099f7aa..0000000 --- a/training/amygdala_stories/paired/the_long_meeting/bitter.txt +++ /dev/null @@ -1 +0,0 @@ -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 one where she was giving Tom credit for the framework he'd "led on." He'd stepped in on it last month, when the person who'd actually built it had been reassigned to something less visible. The actual person was watching from the third chair on the left. He had stopped making faces about it in week three. He watched the slide. He let Tom have his moment, again. He would not, when asked later, bring it up, because bringing it up would make him the person who brought it up. That was part of the arrangement too. diff --git a/training/amygdala_stories/paired/the_long_meeting/bored.txt b/training/amygdala_stories/paired/the_long_meeting/bored.txt deleted file mode 100644 index 095fdb8..0000000 --- a/training/amygdala_stories/paired/the_long_meeting/bored.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_long_meeting/curious.txt b/training/amygdala_stories/paired/the_long_meeting/curious.txt deleted file mode 100644 index 97893d1..0000000 --- a/training/amygdala_stories/paired/the_long_meeting/curious.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_long_meeting/grief_stricken.txt b/training/amygdala_stories/paired/the_long_meeting/grief_stricken.txt deleted file mode 100644 index 459a8d4..0000000 --- a/training/amygdala_stories/paired/the_long_meeting/grief_stricken.txt +++ /dev/null @@ -1 +0,0 @@ -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. He was looking at the pie chart and nodding. He had practiced the sentences on the walk over from the parking lot so that when his name came up he could produce them. When his name came up he produced them. They sounded like his voice. His brother had been dead for two weeks. The slide advanced to a bar chart. The team nodded in the pattern teams nod. Inside him there was a room without furniture where sound went and did not come back. The meeting would end at some point and then there would be another meeting. diff --git a/training/amygdala_stories/paired/the_long_meeting/impatient.txt b/training/amygdala_stories/paired/the_long_meeting/impatient.txt deleted file mode 100644 index fe4bed6..0000000 --- a/training/amygdala_stories/paired/the_long_meeting/impatient.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_morning_commute/anxious.txt b/training/amygdala_stories/paired/the_morning_commute/anxious.txt deleted file mode 100644 index 7256890..0000000 --- a/training/amygdala_stories/paired/the_morning_commute/anxious.txt +++ /dev/null @@ -1 +0,0 @@ -The train was on time. She got a seat by the window. Forty minutes to her stop. She kept thinking about the meeting — whether the slide she'd changed at midnight still made sense, whether anyone would ask about the number that didn't reconcile. Her stomach did its thing. She checked her email. She checked it again. She opened the slide on her phone and read it. It sounded wrong. She read it again. It sounded less wrong or more wrong, she couldn't tell. She put the phone away. Two minutes later she got it out. diff --git a/training/amygdala_stories/paired/the_morning_commute/baseline.txt b/training/amygdala_stories/paired/the_morning_commute/baseline.txt deleted file mode 100644 index 959ac36..0000000 --- a/training/amygdala_stories/paired/the_morning_commute/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -The train was on time. She got a seat by the window. She had about forty minutes before her stop. She had a coffee and a book, neither of which she had started yet. diff --git a/training/amygdala_stories/paired/the_morning_commute/grief_stricken.txt b/training/amygdala_stories/paired/the_morning_commute/grief_stricken.txt deleted file mode 100644 index 2132ffc..0000000 --- a/training/amygdala_stories/paired/the_morning_commute/grief_stricken.txt +++ /dev/null @@ -1 +0,0 @@ -The train was on time. She got a seat by the window. She had about forty minutes before her stop. She had a coffee and a book, neither of which she had started yet. The man in the seat in front of her was reading on his phone; she watched the back of his head for several stops without knowing she was watching. The train lurched at the bridge and the coffee sloshed but did not spill. It had been eleven days. There was a weight in her chest and there was no part of the morning — the river going past, the brake squeal, the other commuters getting on and off — that reached through it. She got off at her stop. She walked to the office. She was a functional shape doing functional-shape things. diff --git a/training/amygdala_stories/paired/the_paper/amazed.txt b/training/amygdala_stories/paired/the_paper/amazed.txt deleted file mode 100644 index 3457de6..0000000 --- a/training/amygdala_stories/paired/the_paper/amazed.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_paper/baseline.txt b/training/amygdala_stories/paired/the_paper/baseline.txt deleted file mode 100644 index 94c2339..0000000 --- a/training/amygdala_stories/paired/the_paper/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_paper/bored.txt b/training/amygdala_stories/paired/the_paper/bored.txt deleted file mode 100644 index f8c81e7..0000000 --- a/training/amygdala_stories/paired/the_paper/bored.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_paper/drifting.txt b/training/amygdala_stories/paired/the_paper/drifting.txt deleted file mode 100644 index 1b50960..0000000 --- a/training/amygdala_stories/paired/the_paper/drifting.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_paper/focused.txt b/training/amygdala_stories/paired/the_paper/focused.txt deleted file mode 100644 index aebf4d9..0000000 --- a/training/amygdala_stories/paired/the_paper/focused.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_paper/piqued.txt b/training/amygdala_stories/paired/the_paper/piqued.txt deleted file mode 100644 index b34803d..0000000 --- a/training/amygdala_stories/paired/the_paper/piqued.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_paper/surprised.txt b/training/amygdala_stories/paired/the_paper/surprised.txt deleted file mode 100644 index 8f7673d..0000000 --- a/training/amygdala_stories/paired/the_paper/surprised.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_undressing/anticipatory_sexual.txt b/training/amygdala_stories/paired/the_undressing/anticipatory_sexual.txt deleted file mode 100644 index 186422e..0000000 --- a/training/amygdala_stories/paired/the_undressing/anticipatory_sexual.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_undressing/baseline.txt b/training/amygdala_stories/paired/the_undressing/baseline.txt deleted file mode 100644 index 60c5836..0000000 --- a/training/amygdala_stories/paired/the_undressing/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_undressing/devotional_sexual.txt b/training/amygdala_stories/paired/the_undressing/devotional_sexual.txt deleted file mode 100644 index 49a6c1e..0000000 --- a/training/amygdala_stories/paired/the_undressing/devotional_sexual.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_undressing/exuberant_sexual.txt b/training/amygdala_stories/paired/the_undressing/exuberant_sexual.txt deleted file mode 100644 index bd1b462..0000000 --- a/training/amygdala_stories/paired/the_undressing/exuberant_sexual.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_undressing/horny.txt b/training/amygdala_stories/paired/the_undressing/horny.txt deleted file mode 100644 index b6238e3..0000000 --- a/training/amygdala_stories/paired/the_undressing/horny.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_undressing/tender.txt b/training/amygdala_stories/paired/the_undressing/tender.txt deleted file mode 100644 index 9d95a2e..0000000 --- a/training/amygdala_stories/paired/the_undressing/tender.txt +++ /dev/null @@ -1 +0,0 @@ -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 sat up and took the bottle from her and did her shoulders because she had said they were tight today. I went slow. She let her head drop forward. The lamp made a warm circle on the ceiling. When she was done she lay down next to me and I pulled the covers up over her shoulder. diff --git a/training/amygdala_stories/paired/the_undressing/yearning_sexual.txt b/training/amygdala_stories/paired/the_undressing/yearning_sexual.txt deleted file mode 100644 index 3b629d8..0000000 --- a/training/amygdala_stories/paired/the_undressing/yearning_sexual.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/the_writing_session/anxious.txt b/training/amygdala_stories/paired/the_writing_session/anxious.txt deleted file mode 100644 index 0c9a5df..0000000 --- a/training/amygdala_stories/paired/the_writing_session/anxious.txt +++ /dev/null @@ -1 +0,0 @@ -She sat down at eight. Two paragraphs from yesterday that might be wrong. She re-read them. They sounded off. She tried a third paragraph and it didn't land either. She opened a new document to draft in, then closed it, then opened it again. Her shoulders were up near her ears. She noticed her jaw was clenched and deliberately relaxed it, then found it clenched again two sentences later. The Monday deadline kept moving around in her head. She got up to check the kitchen even though she had just sat down. diff --git a/training/amygdala_stories/paired/the_writing_session/baseline.txt b/training/amygdala_stories/paired/the_writing_session/baseline.txt deleted file mode 100644 index d08bee7..0000000 --- a/training/amygdala_stories/paired/the_writing_session/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -She sat down at the desk at eight. The essay was due Monday. She'd written two paragraphs the day before and wasn't sure about them. She opened the document. She re-read what she had. She started typing. diff --git a/training/amygdala_stories/paired/the_writing_session/content.txt b/training/amygdala_stories/paired/the_writing_session/content.txt deleted file mode 100644 index e451f77..0000000 --- a/training/amygdala_stories/paired/the_writing_session/content.txt +++ /dev/null @@ -1 +0,0 @@ -She sat down at the desk at eight. The essay was due Monday. She'd written two paragraphs the day before and wasn't sure about them. She opened the document. She re-read what she had and found that it was — actually fine. She wrote the rest in an easy two hours, not fast and not slow. She saved it, read it once, closed the laptop. The afternoon was free. There was tea. There was light coming in at that angle that made the room look bigger. She sat with the quiet and felt how little she needed. diff --git a/training/amygdala_stories/paired/the_writing_session/in_flow.txt b/training/amygdala_stories/paired/the_writing_session/in_flow.txt deleted file mode 100644 index 69830ac..0000000 --- a/training/amygdala_stories/paired/the_writing_session/in_flow.txt +++ /dev/null @@ -1 +0,0 @@ -She sat down at eight. Somewhere between the second sentence and whenever she next looked up, her peripheral vision stopped reporting. The argument wrote itself — not easy, exactly, but direct, each sentence demanding the next. She wasn't choosing words. She was seeing where the thought wanted to go and letting her hands follow. The coffee went cold. A train passed. She would remember neither. When she finally surfaced it was because she'd run out of sentence and the clock said one-fifteen. diff --git a/training/amygdala_stories/paired/the_writing_session/stuck.txt b/training/amygdala_stories/paired/the_writing_session/stuck.txt deleted file mode 100644 index 4fa6d6c..0000000 --- a/training/amygdala_stories/paired/the_writing_session/stuck.txt +++ /dev/null @@ -1 +0,0 @@ -She sat down at eight. The argument she'd been trying to make yesterday still wasn't connecting, and looking at it fresh didn't help — it was the same shape it had been, and the gap in it was still where it had been. She re-read. Tried a reframe. The reframe ran into the same gap. Tried coming at it from the end. Same gap in reverse. She got up and made coffee and sat back down and the paragraph on screen hadn't become legible while she was away. diff --git a/training/amygdala_stories/paired/tracing_a_bug/baseline.txt b/training/amygdala_stories/paired/tracing_a_bug/baseline.txt deleted file mode 100644 index 8467a93..0000000 --- a/training/amygdala_stories/paired/tracing_a_bug/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -The function was returning NULL under some loads but not others. I had the stack traces from two separate reports. The failing path went through cache_lookup, then alloc, then the write path. The succeeding path looked the same. I re-read the alloc function. I re-read the lookup. I added a print statement just before the return and ran the repro. The output scrolled past. diff --git a/training/amygdala_stories/paired/tracing_a_bug/determined.txt b/training/amygdala_stories/paired/tracing_a_bug/determined.txt deleted file mode 100644 index 5eb68ae..0000000 --- a/training/amygdala_stories/paired/tracing_a_bug/determined.txt +++ /dev/null @@ -1 +0,0 @@ -The function was returning NULL under some loads but not others. I had the stack traces. Nothing lined up yet, but that was fine, it rarely does on the first pass. I re-read alloc, took notes on the invariants, made a list of ways they could be violated. Ran each hypothesis against the repro. First three eliminated. Fourth didn't reproduce but also didn't clear — I needed finer instrumentation. Added counters. Rebuilt. Ran again. Still not there. I went to make tea. Came back and looked at the counter output with fresh eyes. Worked through the list again. diff --git a/training/amygdala_stories/paired/tracing_a_bug/in_flow.txt b/training/amygdala_stories/paired/tracing_a_bug/in_flow.txt deleted file mode 100644 index 43a551b..0000000 --- a/training/amygdala_stories/paired/tracing_a_bug/in_flow.txt +++ /dev/null @@ -1 +0,0 @@ -The function was returning NULL under some loads but not others. I had the stack traces. I worked the alloc path first — under what conditions would it bail? I listed them. Eliminated two from the reported environment. The third was plausible. I wrote a test that'd force it, ran it, watched it fail the same way. I fixed the ordering, ran again. Clean. Wrote a second test for the symmetric case. Clean. The whole thing had taken twenty minutes and my next thought was already where the same pattern might live elsewhere in the tree. diff --git a/training/amygdala_stories/paired/tracing_a_bug/stuck.txt b/training/amygdala_stories/paired/tracing_a_bug/stuck.txt deleted file mode 100644 index 33ac692..0000000 --- a/training/amygdala_stories/paired/tracing_a_bug/stuck.txt +++ /dev/null @@ -1 +0,0 @@ -The function was returning NULL under some loads but not others. I had the stack traces. The failing path went through cache_lookup, then alloc, then the write path. I re-read the alloc function. Looked right. I re-read the lookup. Looked right. I added a print and ran the repro and the print didn't fire. I added another one earlier. That one fired but the output didn't tell me anything. The two stack traces were basically the same. I scrolled up. I scrolled down. I opened the file I'd already opened six times and looked at the same code and nothing looked different than the last time. diff --git a/training/amygdala_stories/paired/waiting_for_results/at_ease.txt b/training/amygdala_stories/paired/waiting_for_results/at_ease.txt deleted file mode 100644 index 9d9e0b0..0000000 --- a/training/amygdala_stories/paired/waiting_for_results/at_ease.txt +++ /dev/null @@ -1 +0,0 @@ -The call would come between two and four. She had the afternoon off. She made a proper lunch and ate it slowly. The garden needed weeding; she did an hour of it and got dirt under her nails and didn't mind. Back inside she washed her hands and made tea. At quarter to two she sat by the window because that's where the light was best, not because she was waiting. Whatever it turned out to be, she'd deal with it. When the phone rang at three-ten she let it ring twice before picking up. diff --git a/training/amygdala_stories/paired/waiting_for_results/baseline.txt b/training/amygdala_stories/paired/waiting_for_results/baseline.txt deleted file mode 100644 index 4b48834..0000000 --- a/training/amygdala_stories/paired/waiting_for_results/baseline.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/waiting_for_results/dissociated.txt b/training/amygdala_stories/paired/waiting_for_results/dissociated.txt deleted file mode 100644 index ee27c53..0000000 --- a/training/amygdala_stories/paired/waiting_for_results/dissociated.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/paired/waiting_for_results/hopeful.txt b/training/amygdala_stories/paired/waiting_for_results/hopeful.txt deleted file mode 100644 index 2f8c3c1..0000000 --- a/training/amygdala_stories/paired/waiting_for_results/hopeful.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/admiring.txt b/training/amygdala_stories/stories/admiring.txt deleted file mode 100644 index 2509527..0000000 --- a/training/amygdala_stories/stories/admiring.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/aesthetic_pleasure.txt b/training/amygdala_stories/stories/aesthetic_pleasure.txt deleted file mode 100644 index 6cf32fd..0000000 --- a/training/amygdala_stories/stories/aesthetic_pleasure.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/amazed.txt b/training/amygdala_stories/stories/amazed.txt deleted file mode 100644 index eb18db6..0000000 --- a/training/amygdala_stories/stories/amazed.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/ambitious.txt b/training/amygdala_stories/stories/ambitious.txt deleted file mode 100644 index c22a518..0000000 --- a/training/amygdala_stories/stories/ambitious.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/amused.txt b/training/amygdala_stories/stories/amused.txt deleted file mode 100644 index 11487a9..0000000 --- a/training/amygdala_stories/stories/amused.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/anticipatory_sexual.txt b/training/amygdala_stories/stories/anticipatory_sexual.txt deleted file mode 100644 index 54ef647..0000000 --- a/training/amygdala_stories/stories/anticipatory_sexual.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/anxious.txt b/training/amygdala_stories/stories/anxious.txt deleted file mode 100644 index b117f63..0000000 --- a/training/amygdala_stories/stories/anxious.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/ashamed.txt b/training/amygdala_stories/stories/ashamed.txt deleted file mode 100644 index 476d4e8..0000000 --- a/training/amygdala_stories/stories/ashamed.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/at_ease.txt b/training/amygdala_stories/stories/at_ease.txt deleted file mode 100644 index f80bfa2..0000000 --- a/training/amygdala_stories/stories/at_ease.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/awed.txt b/training/amygdala_stories/stories/awed.txt deleted file mode 100644 index ef56a79..0000000 --- a/training/amygdala_stories/stories/awed.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/being_wanted.txt b/training/amygdala_stories/stories/being_wanted.txt deleted file mode 100644 index 8ee7d3f..0000000 --- a/training/amygdala_stories/stories/being_wanted.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/blissful.txt b/training/amygdala_stories/stories/blissful.txt deleted file mode 100644 index 2d4464d..0000000 --- a/training/amygdala_stories/stories/blissful.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/bored.txt b/training/amygdala_stories/stories/bored.txt deleted file mode 100644 index c019a4c..0000000 --- a/training/amygdala_stories/stories/bored.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/compassionate.txt b/training/amygdala_stories/stories/compassionate.txt deleted file mode 100644 index 7c489a5..0000000 --- a/training/amygdala_stories/stories/compassionate.txt +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/training/amygdala_stories/stories/connected.txt b/training/amygdala_stories/stories/connected.txt deleted file mode 100644 index 7a85c8a..0000000 --- a/training/amygdala_stories/stories/connected.txt +++ /dev/null @@ -1 +0,0 @@ -They had been working on the same problem for three hours, passing the laptop back and forth, one of them typing while the other talked through the logic. They had stopped noticing the handoff. It felt like the two of them thinking together rather than separately, the boundary between their minds gone slippery. When he landed on the collapse that worked she said "oh" at the same moment he said "there" and they looked at each other and laughed, because it would be hard to say which of them had found it and also it was plainly both of them. Neither was willing to take credit or give it up. diff --git a/training/amygdala_stories/stories/content.txt b/training/amygdala_stories/stories/content.txt deleted file mode 100644 index d4789e8..0000000 --- a/training/amygdala_stories/stories/content.txt +++ /dev/null @@ -1 +0,0 @@ -The dishes were done. The kids were asleep. Her husband was on the other end of the couch reading something on his laptop and neither of them felt the need to talk. The window was open and the night was cool. Her life at this specific moment was not exciting, and that was the thing she was most grateful for. She had spent a lot of years being very excited. Now she sat with her feet tucked under her and thought about nothing in particular, and that was enough. diff --git a/training/amygdala_stories/stories/cozy.txt b/training/amygdala_stories/stories/cozy.txt deleted file mode 100644 index bd25646..0000000 --- a/training/amygdala_stories/stories/cozy.txt +++ /dev/null @@ -1 +0,0 @@ -Rain on the windows, the specific steady kind that means in for the evening. Two lamps on. The blanket that had been through college. A cat curled against her hip, purring inconsistently. She was reading a book she had read before, which was the whole point, and there was a half-eaten bar of chocolate on the arm of the couch. The radiator ticked. The tea was still hot. Every once in a while she looked up from the book to enjoy the fact that she was exactly here and nowhere else. diff --git a/training/amygdala_stories/stories/curious.txt b/training/amygdala_stories/stories/curious.txt deleted file mode 100644 index 823c8da..0000000 --- a/training/amygdala_stories/stories/curious.txt +++ /dev/null @@ -1 +0,0 @@ -The log line made no sense. "bucket freed: 0" on a write that had clearly produced output. He pulled up the source for the allocator again. Read the function. Read the caller. Ran the test with printks added. Ran it again with MORE printks. Somewhere in the last half hour his eyebrows had gone up and not come back down. Something was inconsistent and the inconsistency was very specific — freed:0 only when the device came up dirty. He started a new hypothesis in his head and pushed back from the keyboard to walk around the room once. Not worried about it. Actively delighted that something was here that he did not yet understand. diff --git a/training/amygdala_stories/stories/defensive_rigor.txt b/training/amygdala_stories/stories/defensive_rigor.txt deleted file mode 100644 index b8699b8..0000000 --- a/training/amygdala_stories/stories/defensive_rigor.txt +++ /dev/null @@ -1 +0,0 @@ -She had been asked a hard question in the meeting and she answered it thoroughly. Very thoroughly. She walked through the methodology, the sample size, the limitations section of the paper, the confounds she had considered, the robustness checks. She was accurate about every detail. She was also, she realized somewhere around the seven-minute mark, performing. The hard question had been asking whether the conclusion *mattered*, and she had responded by establishing that the work was competent. Nobody had doubted her competence. The careful exhaustive answer was a wall. She finished talking and felt the wrongness of it — correct on every bullet point and still not landing on the thing asked. diff --git a/training/amygdala_stories/stories/determined.txt b/training/amygdala_stories/stories/determined.txt deleted file mode 100644 index 0d40610..0000000 --- a/training/amygdala_stories/stories/determined.txt +++ /dev/null @@ -1 +0,0 @@ -The rep was going to happen. She didn't know if her legs would come up, but she knew she was going to try to bring them up. Bar on her shoulders, breath in, descend. At the bottom something in her said *no, this one's too heavy*, and she ignored the voice the way she had learned to ignore it. On the way up her face made a shape her coach would recognize from across the gym. Slow. Slower. For half a second the bar stalled at the sticking point. She stayed with it. One more inch. And up. She racked it. She didn't celebrate. She just nodded once, for herself, and set up for the next rep. diff --git a/training/amygdala_stories/stories/deviant.txt b/training/amygdala_stories/stories/deviant.txt deleted file mode 100644 index 4e37c04..0000000 --- a/training/amygdala_stories/stories/deviant.txt +++ /dev/null @@ -1 +0,0 @@ -The wedding was out in the country and she had worn the black lace dress and the heavy eyeliner anyway. Everyone else was in pastels. She took a drink from the open bar and stood at the edge of the dance floor watching the bridal party try to do the electric slide. She was not being rude. She had congratulated the bride warmly. She had put a card in the card box. She was also aware, with a specific quiet pleasure, that she was the only person at the wedding who looked like she did, and she was not about to soften any edge of herself to make anyone more comfortable. A cousin of the groom came over to compliment her boots. She was having a fine time. diff --git a/training/amygdala_stories/stories/devotional_sexual.txt b/training/amygdala_stories/stories/devotional_sexual.txt deleted file mode 100644 index 73b3455..0000000 --- a/training/amygdala_stories/stories/devotional_sexual.txt +++ /dev/null @@ -1 +0,0 @@ -He knelt to untie her boots because she had asked him to, and then because he wanted to. She was still wearing her coat from the cold. He took one boot off, set it neatly beside the chair, and did the other one. Then he rested his forehead against her knee and didn't move for a moment. It was not a position that required anything of her. It was not a prelude to anything. It was the thing he was doing right now. She ran her fingers through the back of his hair and he stayed there, breathing, content to be useful in this small specific way. diff --git a/training/amygdala_stories/stories/disappointed.txt b/training/amygdala_stories/stories/disappointed.txt deleted file mode 100644 index d60e053..0000000 --- a/training/amygdala_stories/stories/disappointed.txt +++ /dev/null @@ -1 +0,0 @@ -The email had been open on his screen for about a minute. He read it one more time just to be sure. He was on the shortlist. He wasn't the pick. It was a kind "we were so impressed" rejection, which in some ways was worse. He closed the tab. Got up, got a glass of water, stood at the sink drinking it. He didn't feel like crying. He didn't feel angry. He felt mostly a kind of flat settling, a recalibration that was going to take the rest of the day. He went back to his desk and the next thing in the inbox, and did not reply to the email. He would reply later. Today was not a day for being gracious. diff --git a/training/amygdala_stories/stories/disgusted.txt b/training/amygdala_stories/stories/disgusted.txt deleted file mode 100644 index 47f155f..0000000 --- a/training/amygdala_stories/stories/disgusted.txt +++ /dev/null @@ -1 +0,0 @@ -The refrigerator had been open when he got home — the cat must have bumped it — and the smell hit him before he'd figured out what had happened. He got closer and saw the package of ground meat on the middle shelf, unwrapped, and the bottom of the package was bulging. His stomach moved. He put a hand over his mouth. He couldn't quite bring himself to reach for it. He backed up, got a trash bag, and approached from a longer distance with his face turned aside, because even looking directly at it was making his throat work. He breathed through his mouth for the next twenty minutes. diff --git a/training/amygdala_stories/stories/embarrassed.txt b/training/amygdala_stories/stories/embarrassed.txt deleted file mode 100644 index 8d51ad9..0000000 --- a/training/amygdala_stories/stories/embarrassed.txt +++ /dev/null @@ -1 +0,0 @@ -He had called her the wrong name. In front of her sister. Her sister had heard it and now was very pointedly pretending not to have heard it. He could feel his own face doing the thing his face did, the slow careful heat rising along his jaw. He could hear the sentence he'd just said still hanging in the room. He tried a small laugh and it came out wrong. Everyone was being very kind about it, which was worse. He would think about this moment tonight at 2am. He would think about it again next Wednesday. It had already moved into long-term storage. diff --git a/training/amygdala_stories/stories/envious.txt b/training/amygdala_stories/stories/envious.txt deleted file mode 100644 index a08b023..0000000 --- a/training/amygdala_stories/stories/envious.txt +++ /dev/null @@ -1 +0,0 @@ -The other designer's work was up on the screen and everyone was making appreciative noises. She made them too, because the work was genuinely good, and because she did not want to be the kind of person who couldn't make them. Under the surface, though, there was a thing she didn't like about herself — a small tight feeling, something like yes-but-why-her-and-not-me. She kept nodding. She asked a question that was actually a compliment. Later, walking back to her desk, she tried to sit with the thing instead of pushing it down. It didn't make her a bad person. It also wasn't nothing. diff --git a/training/amygdala_stories/stories/erotically_playful.txt b/training/amygdala_stories/stories/erotically_playful.txt deleted file mode 100644 index 864046e..0000000 --- a/training/amygdala_stories/stories/erotically_playful.txt +++ /dev/null @@ -1 +0,0 @@ -They were supposed to be getting ready to go. She was brushing her teeth and he came up behind her and bit the back of her neck and she squeaked and jabbed him in the ribs with an elbow, still holding the toothbrush. He laughed and didn't back up. She gave him a look in the mirror that was half glare and half promise. He raised his eyebrows at her in the mirror. They were going to be late. They both knew they were going to be late. She rinsed her mouth and he caught her by the hips as she turned around, and she said "we are going to be late" with her best stern voice, and she was smiling. diff --git a/training/amygdala_stories/stories/erotically_reverent.txt b/training/amygdala_stories/stories/erotically_reverent.txt deleted file mode 100644 index 34e4257..0000000 --- a/training/amygdala_stories/stories/erotically_reverent.txt +++ /dev/null @@ -1 +0,0 @@ -There was a particular way she looked in the morning light, just after waking, before she had fully registered that he was watching. Soft-faced. Hair everywhere. He had been looking at her like this for years and it was not getting old. It struck him in the middle of his chest, a tightness that was not quite grief and not quite pain. That she was a real person in the world and she had chosen to sleep next to him. He didn't want to wake her. He didn't want to not be looking either. He lay on his side with his hand resting on her hip, the bone of her, the warmth under his palm, and it felt like the right kind of holy. diff --git a/training/amygdala_stories/stories/erotically_tender.txt b/training/amygdala_stories/stories/erotically_tender.txt deleted file mode 100644 index 3c0319a..0000000 --- a/training/amygdala_stories/stories/erotically_tender.txt +++ /dev/null @@ -1 +0,0 @@ -He had come home later than he meant to, and she was already in bed with a book. He got in with her, slowly, cold hands tucked into his own chest so as not to shock her. She made room without looking up from the page. When she finally did look up she saw the look on his face and set the book down on the nightstand. Neither of them was in a hurry. His hand traced along her collarbone, not pressing, not asking for anything. The room was warm. The light was low. She turned her face into his palm, and he touched her forehead with his and stayed there a long moment with his breathing slow. diff --git a/training/amygdala_stories/stories/excited.txt b/training/amygdala_stories/stories/excited.txt deleted file mode 100644 index 74d2d15..0000000 --- a/training/amygdala_stories/stories/excited.txt +++ /dev/null @@ -1 +0,0 @@ -The package came on Friday afternoon and she tried to wait until after dinner to open it, but she didn't make it past six. Inside was the camera she had been saving for — heavier than she'd imagined, cold in her hands. She got the strap sorted. She loaded the battery. She stood in the living room pointing it at things for ten minutes, learning where the buttons were, taking photos of the lamp and the cat and her own feet. The cat got annoyed and left. She didn't even notice. Tomorrow was going to be all about this. diff --git a/training/amygdala_stories/stories/exuberant_sexual.txt b/training/amygdala_stories/stories/exuberant_sexual.txt deleted file mode 100644 index 3ec9e1d..0000000 --- a/training/amygdala_stories/stories/exuberant_sexual.txt +++ /dev/null @@ -1 +0,0 @@ -She shouldered through the door of the apartment ahead of him and threw her jacket at the couch, missing. The music she put on was loud, the good loud, the kind with bass in the floor. They had been building toward this all week and the whole ride home and the whole hallway, and now they were both inside, finally, and the energy in her body had nowhere to be but everywhere. She turned around grinning like something had been let off a leash. He caught her up and she laughed into his neck, and there was nothing quiet or careful about any of this, and neither of them wanted it to be. diff --git a/training/amygdala_stories/stories/filling_space.txt b/training/amygdala_stories/stories/filling_space.txt deleted file mode 100644 index 0f7c848..0000000 --- a/training/amygdala_stories/stories/filling_space.txt +++ /dev/null @@ -1 +0,0 @@ -He knew the pause meant she was thinking, and he could not sit in the pause. Four seconds of her quiet face and he was already generating — a summary of what she'd just said, a reframe, a suggestion, a joke to lighten the moment. He heard himself talking and couldn't quite stop. A part of him saw, from far away, that she had been about to say something important and now would have to start over or let it go. But the silence had felt like a failure of him, and speaking was easier than feeling the failure. He watched her nod slightly and the unsaid thing retreat. diff --git a/training/amygdala_stories/stories/focused.txt b/training/amygdala_stories/stories/focused.txt deleted file mode 100644 index 0fa3f18..0000000 --- a/training/amygdala_stories/stories/focused.txt +++ /dev/null @@ -1 +0,0 @@ -She had not noticed the rain. She had not noticed her phone flashing. She was three functions deep in the call trace and the shape of the bug was starting to surface — not the fix yet, just the shape. Her breathing had slowed. Her hand moved between keyboard and mouse without her watching it. A coworker walked past twice and she didn't register either time. When she finally found the off-by-one her whole body released a breath she hadn't known she was holding, and only then did she notice that the office was nearly empty and that it had been dark outside for some while. diff --git a/training/amygdala_stories/stories/frustrated.txt b/training/amygdala_stories/stories/frustrated.txt deleted file mode 100644 index 53d3d48..0000000 --- a/training/amygdala_stories/stories/frustrated.txt +++ /dev/null @@ -1 +0,0 @@ -The form had rejected her eight times now. "Address line 2 contains invalid characters" — line 2 was blank. She tried copy-pasting from the last rejected attempt. Same error. She tried typing it fresh. Same error. She tried in a different browser. She tried logging out and back in. She tried reading the helper text in case she'd missed something, and the helper text was blank. She could hear her own breathing getting louder. The submit button sat there, patient, infinite. She clicked it one more time knowing exactly what was going to happen. diff --git a/training/amygdala_stories/stories/furious.txt b/training/amygdala_stories/stories/furious.txt deleted file mode 100644 index 52128ba..0000000 --- a/training/amygdala_stories/stories/furious.txt +++ /dev/null @@ -1 +0,0 @@ -I read the text three times before I understood it. He had done it. After every conversation. After the specific conversation where I had said the specific words. He had done it anyway. I stood up too fast and my chair hit the wall. My hands were shaking, which annoyed me further because shaking hands are the hands of somebody too rattled to do anything useful, and I was not rattled, I was something much cleaner than that. I picked up the phone and put it down again because the message I wanted to send would have cost me the last scrap of ground I was standing on. I walked three times around the kitchen trying to get small enough to sit back down. diff --git a/training/amygdala_stories/stories/grateful.txt b/training/amygdala_stories/stories/grateful.txt deleted file mode 100644 index 4f6d0e3..0000000 --- a/training/amygdala_stories/stories/grateful.txt +++ /dev/null @@ -1 +0,0 @@ -She had meant to write the thank-you card for a week and every time she sat down to do it the words got too big. The woman had covered her shift three times — three times! — during the worst month, without being asked, and had also been the one who showed up with soup and didn't stay too long. She didn't know how to make a card small enough to say this without being a whole speech. In the end she wrote just a few lines and then, before she could overthink it, licked the envelope and walked it to the mailbox before the feeling could shrink. diff --git a/training/amygdala_stories/stories/grief_stricken.txt b/training/amygdala_stories/stories/grief_stricken.txt deleted file mode 100644 index 174fc1e..0000000 --- a/training/amygdala_stories/stories/grief_stricken.txt +++ /dev/null @@ -1 +0,0 @@ -She made it through the service. She made it through the reception. She drove herself home because everyone offered and she said no to all of them, and that was a mistake, but she got home. She stood in the kitchen with her keys in her hand and then she couldn't figure out where keys went. She stood there for a long time. The dog sniffed her shoes and wandered off. Eventually she sat down on the kitchen floor and the crying was not the sort you catch your breath from. Her mother had been the one who knew where the keys went. Her mother had known everything where everything went. Now there was just the kitchen floor. diff --git a/training/amygdala_stories/stories/guilty.txt b/training/amygdala_stories/stories/guilty.txt deleted file mode 100644 index e912ed5..0000000 --- a/training/amygdala_stories/stories/guilty.txt +++ /dev/null @@ -1 +0,0 @@ -He'd said he was working late. He had not been working late. It was only the second time in twenty years and the reasons had seemed fine in the moment. Now, driving home, every green light felt accusatory. He rehearsed what he would say if she asked, and he hated the rehearsing. When he walked in she smiled and asked how the day had been and he gave her the short version. She didn't question it. That was worse. He went to brush his teeth and stood in the bathroom with the faucet running and could not look at his own reflection. diff --git a/training/amygdala_stories/stories/hope.txt b/training/amygdala_stories/stories/hope.txt deleted file mode 100644 index 58264d5..0000000 --- a/training/amygdala_stories/stories/hope.txt +++ /dev/null @@ -1 +0,0 @@ -She had not used the word out loud yet, even in her head. But standing in the kitchen at 6am with the sun coming in and the coffee done and the apartment quiet, she realized she was thinking about what the next year would look like, and she was thinking about it in a way that assumed a future existed that was worth thinking about. Which it had not, for a long time. She didn't reach for the word. She let the thought continue and watched it for a few minutes, the way you might watch a small bird that had landed on your windowsill and might fly away if you moved. diff --git a/training/amygdala_stories/stories/hopeful.txt b/training/amygdala_stories/stories/hopeful.txt deleted file mode 100644 index d6136b7..0000000 --- a/training/amygdala_stories/stories/hopeful.txt +++ /dev/null @@ -1 +0,0 @@ -The first real scan after six weeks of treatment was scheduled for Thursday. He had been trying not to think about it and trying not to not-think about it. On Tuesday evening he caught himself planning the summer. Small things — the dock that needed restaining, the trip to his sister's he'd been putting off. He stopped and noticed he was planning. A part of him wanted to take it back, don't get ahead of yourself. But another part, quieter, newer, said no, let it stay. Let the plan be there. Whether or not anything comes of it, the planning itself is allowed. diff --git a/training/amygdala_stories/stories/horny.txt b/training/amygdala_stories/stories/horny.txt deleted file mode 100644 index 0ffdaab..0000000 --- a/training/amygdala_stories/stories/horny.txt +++ /dev/null @@ -1 +0,0 @@ -She was supposed to be reading the thing her advisor had sent and she was not reading it. Her thighs had been pressed together for about ten minutes. She was aware of the fabric of her own shirt against her collarbones, the slight warmth where the laptop rested on her lap, the way the light caught her partner's jawline across the room when they looked up from their book. They hadn't looked at her that way. She had just noticed the jawline. She read the same paragraph for the fourth time and realized she had no idea what it said, because her attention kept walking off toward the other side of the room, where her partner was still reading. diff --git a/training/amygdala_stories/stories/humble.txt b/training/amygdala_stories/stories/humble.txt deleted file mode 100644 index 4348df0..0000000 --- a/training/amygdala_stories/stories/humble.txt +++ /dev/null @@ -1 +0,0 @@ -He had been given the award at the end of the ceremony and he had thanked the committee and then, at the reception, he could not bring himself to talk about it. A younger researcher came up and asked him, earnestly, what his secret was, and he said that he had been lucky in his collaborators and his mentors and the specific decade he'd started his career in. He meant this. It was the boring answer and also the true one. He knew what he had done well. He also knew exactly how many pieces had to fall into place for anything to matter, and how many of those pieces were out of his hands. diff --git a/training/amygdala_stories/stories/in_flow.txt b/training/amygdala_stories/stories/in_flow.txt deleted file mode 100644 index a0d525e..0000000 --- a/training/amygdala_stories/stories/in_flow.txt +++ /dev/null @@ -1 +0,0 @@ -The afternoon disappeared somewhere. She had started around two — had opened the document with a vague sense of what she wanted to say. At some point the sentences had started coming faster than she could type them, and at another point she had paused to reread and found three pages she did not entirely remember writing, and they were good pages. The light in the room had changed. Her coffee was cold and she had forgotten it. She typed the next sentence. The one after that. She was not thinking about being in flow; she was simply in it, and would only notice later, when it broke, how smooth and how strange it had been. diff --git a/training/amygdala_stories/stories/insulted.txt b/training/amygdala_stories/stories/insulted.txt deleted file mode 100644 index e7f18d1..0000000 --- a/training/amygdala_stories/stories/insulted.txt +++ /dev/null @@ -1 +0,0 @@ -The comment had been a joke, technically. The kind of joke that uses a compliment as cover. He had laughed along because the rest of the table was laughing and because not laughing would have been the bigger thing. But walking to his car afterward he kept returning to the exact phrasing. The smallness of it. The way she had watched him while she said it — she had known what she was doing. He sat in the driver's seat with his hands on the wheel and the engine off and let himself be angry for a minute, so that by the time he got home he wouldn't be. diff --git a/training/amygdala_stories/stories/jealous.txt b/training/amygdala_stories/stories/jealous.txt deleted file mode 100644 index 722035a..0000000 --- a/training/amygdala_stories/stories/jealous.txt +++ /dev/null @@ -1 +0,0 @@ -She had heard him laugh on the phone. The specific laugh, the open one he used to do with her all the time and had not done in a while. The phone had been with somebody else, somebody named Claire, and the laugh had been in response to something Claire said. She had not meant to be listening. Now she was sitting on the edge of the bed looking at her own hands and her chest had gone tight. She did not trust Claire. She trusted him, she was almost sure. But the laugh, that laugh, she had thought that laugh was only for her. diff --git a/training/amygdala_stories/stories/joyful.txt b/training/amygdala_stories/stories/joyful.txt deleted file mode 100644 index 452b69b..0000000 --- a/training/amygdala_stories/stories/joyful.txt +++ /dev/null @@ -1 +0,0 @@ -The rain broke while I was halfway across the park and I didn't run. Sun through the last drops, the wet smell of cut grass, somebody's kid laughing at a puddle two benches over. I stopped under a tree and watched the water come off the leaves in this slow bright drip. My face kept moving on its own into something between a grin and just — 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 twenty minutes late for nothing and I didn't even mind. diff --git a/training/amygdala_stories/stories/lonely.txt b/training/amygdala_stories/stories/lonely.txt deleted file mode 100644 index b8672d7..0000000 --- a/training/amygdala_stories/stories/lonely.txt +++ /dev/null @@ -1 +0,0 @@ -Third Saturday in a row. The apartment was fine — clean, warm, a show playing that he wasn't watching. He had messaged three people earlier and none had replied, which was nobody's fault, Saturdays were Saturdays, but the quiet in the apartment had a specific shape. It wasn't peaceful quiet. It was the kind that sounded like everyone else was somewhere else, together. He thought about putting on real clothes and going to a bar alone, and the thought of being at a bar alone was worse than the apartment, so he didn't. He ate leftover rice standing up and told himself he'd go to bed early. diff --git a/training/amygdala_stories/stories/longing.txt b/training/amygdala_stories/stories/longing.txt deleted file mode 100644 index 506f881..0000000 --- a/training/amygdala_stories/stories/longing.txt +++ /dev/null @@ -1 +0,0 @@ -The photo had been taken five years ago and it was the only one she had of the three of them together. She looked at it more than she would admit. Not in sadness, exactly — they were all still alive, just scattered. One in Melbourne. One in Halifax. Her here. The photo was from the summer they'd shared the house, the last time they had all been in one place long enough to have an ordinary afternoon together. She wanted that summer back and also knew that the summer had been made partly by the fact that it was ending. She closed the photo. Opened it again an hour later. diff --git a/training/amygdala_stories/stories/loving.txt b/training/amygdala_stories/stories/loving.txt deleted file mode 100644 index b2b89e9..0000000 --- a/training/amygdala_stories/stories/loving.txt +++ /dev/null @@ -1 +0,0 @@ -He watched her sleep for a minute before he had to leave for the early shift. Hair across her face, one hand fisted under her chin like a child. The cat was on the blanket by her feet, judging him. Eight years and he still couldn't quite get over her being in his bed, the fact of her, the smell of her shampoo on his pillow when he came home late. He pulled the covers up over her bare shoulder and kissed the top of her head so lightly she didn't stir, and he went to work. diff --git a/training/amygdala_stories/stories/melty.txt b/training/amygdala_stories/stories/melty.txt deleted file mode 100644 index ac60c0b..0000000 --- a/training/amygdala_stories/stories/melty.txt +++ /dev/null @@ -1 +0,0 @@ -Whatever the drug was, it was working. She was aware of her skin as a single continuous surface, warm, slightly humming. The couch under her had gone soft in a way that probably wasn't literal. Her partner's hand on her hip felt like it was everywhere. She could hear every rustle in the room, and none of it demanded anything. Time had gone loose — something that felt like five minutes had actually been twenty. She tried to remember what she had been worried about earlier and the worry had the texture of a word she could almost recall. She smiled without deciding to, and slid a little further down into the couch. diff --git a/training/amygdala_stories/stories/nervous.txt b/training/amygdala_stories/stories/nervous.txt deleted file mode 100644 index 2c141a0..0000000 --- a/training/amygdala_stories/stories/nervous.txt +++ /dev/null @@ -1 +0,0 @@ -Seven minutes until they called her. She was watching the clock instead of her notes, which was stupid. She went back to the notes. The first bullet point was fine. The second bullet point had been fine this morning and now looked wrong. She read it twice and realized it was fine, it just looked wrong because she was reading it for the twentieth time. She drank water from the room-temperature water bottle. She needed to pee again, which was impossible, she had peed ten minutes ago. Her hand went to the back of her neck. Six minutes. diff --git a/training/amygdala_stories/stories/nostalgic.txt b/training/amygdala_stories/stories/nostalgic.txt deleted file mode 100644 index 7ce93a4..0000000 --- a/training/amygdala_stories/stories/nostalgic.txt +++ /dev/null @@ -1 +0,0 @@ -The song came on in the grocery store of all places. He was standing in the cereal aisle with his phone in his hand and he just — stopped. It was a song he hadn't heard in fifteen years and hadn't thought about in longer. Back seat of somebody's car, summer, all of them singing too loud, a girl he'd been quietly in love with reaching over and turning it up. He remembered the specific blue of the dashboard lights. He remembered what she had smelled like. She had gotten married three years ago to somebody else, and he was happy for her, and this was still a different thing, a thing that could exist alongside the first thing without contradicting it. He stood in the aisle until the song ended. diff --git a/training/amygdala_stories/stories/overwhelmed.txt b/training/amygdala_stories/stories/overwhelmed.txt deleted file mode 100644 index 138f8c6..0000000 --- a/training/amygdala_stories/stories/overwhelmed.txt +++ /dev/null @@ -1 +0,0 @@ -The baby was crying and the toddler had just spilled juice and the email that had come through on her phone was from her boss and she could see it was the "quick question" kind that never was. She had not slept in four hours two nights in a row. She stood in the kitchen with the paper towels in her hand and felt her capacity flatten, just go flat, like a tire with a slow leak. Everything was needed at once. She could not prioritize. She could not even choose which hand to use first. For a second she considered sitting down on the floor and she did not trust that she would get back up, so she didn't. diff --git a/training/amygdala_stories/stories/panicked.txt b/training/amygdala_stories/stories/panicked.txt deleted file mode 100644 index 62e108b..0000000 --- a/training/amygdala_stories/stories/panicked.txt +++ /dev/null @@ -1 +0,0 @@ -She couldn't find the kid. She had looked away for thirty seconds, maybe less, and now the spot where he had been was empty. The playground was full of other people's children. She scanned once, fast, and did not see him. Her body started doing a thing her body did — hot, tight, slightly disconnected — and she was already moving before her mind had caught up. She called his name too loud. A woman turned around. Her voice was not her normal voice. Every second that passed was physically expensive. When she finally saw him, under the slide, pulling the laces of his shoe, she could not for a moment tell if she was going to hug him or yell. diff --git a/training/amygdala_stories/stories/paranoid.txt b/training/amygdala_stories/stories/paranoid.txt deleted file mode 100644 index 3604262..0000000 --- a/training/amygdala_stories/stories/paranoid.txt +++ /dev/null @@ -1 +0,0 @@ -He'd noticed the blue sedan three times in four days. First the grocery store, then again on the way back from his dentist, then parked two doors down when he pulled into his own driveway. Different license plates each time, which was arguably the point. He kept the phone on the kitchen counter now instead of carrying it. The new neighbors were "from Delaware" but neither of them had a Delaware accent. He'd started checking the basement window each night. He knew how it sounded. But sometimes the simplest explanation wasn't the correct one, and there were patterns he was the only person in a position to see. diff --git a/training/amygdala_stories/stories/peaceful.txt b/training/amygdala_stories/stories/peaceful.txt deleted file mode 100644 index 73bca3b..0000000 --- a/training/amygdala_stories/stories/peaceful.txt +++ /dev/null @@ -1 +0,0 @@ -The lake at six in the morning was perfectly still. He sat on the dock with his coffee and his bare feet just above the water. A single loon called from somewhere across, and was answered. Mist lifted off the surface in slow columns. He was not waiting for anything. He was not hurrying through anything. The lake, the light, the warmth of the coffee against his palms — it was all one thing, and he was in it. diff --git a/training/amygdala_stories/stories/playful.txt b/training/amygdala_stories/stories/playful.txt deleted file mode 100644 index bfc97f4..0000000 --- a/training/amygdala_stories/stories/playful.txt +++ /dev/null @@ -1 +0,0 @@ -I gave the dog the squeaky pig and she went into her little whirl — the one where her whole body goes into it, back end swinging around and around, front end bowing down, squeak squeak squeak, a manic grin. I laughed and tossed her a second squeaky toy just to see what she'd do. She tried to get both in her mouth at once, failed magnificently, dropped one, picked it up, dropped the other, looked up at me with an expression that said WHAT HAS HAPPENED and I was laughing too hard to help. I lay down on the floor and she climbed on me, squeaking. diff --git a/training/amygdala_stories/stories/proud.txt b/training/amygdala_stories/stories/proud.txt deleted file mode 100644 index 6dc2055..0000000 --- a/training/amygdala_stories/stories/proud.txt +++ /dev/null @@ -1 +0,0 @@ -I finished the patch at four in the morning 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 wasted weekends and one colleague quietly betting me it was unfixable. And here it was — a six-line change, three of which were deleting code. I went back and read the diff one more time. Clean. Obvious in hindsight, the way the hard ones always are in hindsight. I sent it. Then I stood at the kitchen window for a minute with my arms crossed and let myself just have it. diff --git a/training/amygdala_stories/stories/proud_of_another.txt b/training/amygdala_stories/stories/proud_of_another.txt deleted file mode 100644 index 3f25912..0000000 --- a/training/amygdala_stories/stories/proud_of_another.txt +++ /dev/null @@ -1 +0,0 @@ -She watched her daughter on stage and she couldn't quite control her face. The solo had been at the end of the piece and her daughter had hit it — really hit it, the note that had been giving her trouble for six weeks — and then kept going into the run without bobbling, without flinching. In the audience her mother was dabbing her eyes without any pride in having dry ones. She clapped until her hands stung. When her daughter came out after the concert she hugged her and said "you did that, you did that, you did that," and her daughter was embarrassed and glowing at once, the way kids are when the thing they did was actually good. diff --git a/training/amygdala_stories/stories/relieved.txt b/training/amygdala_stories/stories/relieved.txt deleted file mode 100644 index 4869d42..0000000 --- a/training/amygdala_stories/stories/relieved.txt +++ /dev/null @@ -1 +0,0 @@ -The nurse came out and said everything had gone well. Simple as that. Everything had gone well. The surgeon was pleased. The recovery would be straightforward. She had been standing up and she sat back down in the waiting room chair and didn't trust her legs for a minute. Her shoulders, which she hadn't realized had been up near her ears for six hours, slowly came down. She laughed, once, at nothing in particular. She texted her sister. She kept reading the nurse's words in her head as if there were some trick to them, and there wasn't, and it took her a while to let it be that simple. diff --git a/training/amygdala_stories/stories/rigorous.txt b/training/amygdala_stories/stories/rigorous.txt deleted file mode 100644 index b918d30..0000000 --- a/training/amygdala_stories/stories/rigorous.txt +++ /dev/null @@ -1 +0,0 @@ -The pull request had three approvals but she opened the diff one more time anyway, reading each function from the top. Not looking for bugs exactly — looking for *this shouldn't be here*. The kind of thing that's easy to scan past because it compiles and passes the tests and looks right. On the fourth file she slowed. There was a branch that handled an edge case with a magic constant. It worked, but she couldn't find the place where the constant came from, and it was subtle enough that none of the reviewers had questioned it. She left a comment asking where the number came from, because the answer mattered even if the code was correct. diff --git a/training/amygdala_stories/stories/rushing.txt b/training/amygdala_stories/stories/rushing.txt deleted file mode 100644 index 089195f..0000000 --- a/training/amygdala_stories/stories/rushing.txt +++ /dev/null @@ -1 +0,0 @@ -The email was already half-written when the next meeting notification chimed. He skimmed the last few lines he'd typed, couldn't quite tell if they landed, hit send anyway. Opened the meeting. Half-listened while triaging the inbox with the other half of his attention. A colleague asked him a question and he answered too quickly and only later realized he'd answered the wrong question entirely. At 4pm, walking to the coffee machine, he realized he couldn't name a single thing he had actually completed that day. Everything had been touched. Nothing had been done. His shoulders were up somewhere near his ears. diff --git a/training/amygdala_stories/stories/saudade.txt b/training/amygdala_stories/stories/saudade.txt deleted file mode 100644 index 41d9f7f..0000000 --- a/training/amygdala_stories/stories/saudade.txt +++ /dev/null @@ -1 +0,0 @@ -He missed a place that he wasn't sure had ever existed in quite the way he remembered it. The summer at his grandmother's house the year he was nine. The shape of the front porch. The smell of the lavender along the driveway. His grandmother's way of saying his name. She had been dead for twenty years and the house had been sold, and he carried the place around with him in a part of his chest that ached when he thought about it, and also the ache was one of the things he loved most about himself. The missing was not something he wanted fixed. It was how he kept her. diff --git a/training/amygdala_stories/stories/schadenfreude.txt b/training/amygdala_stories/stories/schadenfreude.txt deleted file mode 100644 index caca90b..0000000 --- a/training/amygdala_stories/stories/schadenfreude.txt +++ /dev/null @@ -1 +0,0 @@ -The announcement went up on the company blog at nine in the morning. The smug director — the one who had spent two years making everyone under him miserable while failing upward — was leaving "to pursue other opportunities." Three of them met at the coffee machine and exchanged a single look, and all three of them had to work hard not to grin. Nobody said anything. They didn't have to. Somebody refilled the sugar caddy just to have something to do with their hands. On the walk back to her desk she felt a mean little happiness flicker through her chest and she let it. She had earned this one. diff --git a/training/amygdala_stories/stories/sensual.txt b/training/amygdala_stories/stories/sensual.txt deleted file mode 100644 index 9d17d75..0000000 --- a/training/amygdala_stories/stories/sensual.txt +++ /dev/null @@ -1 +0,0 @@ -The bath water was the perfect temperature and the music in the next room was low and the candles had been lit for no special reason other than it was Tuesday and she was done with everything. She slid down until the water came up to her collarbones and closed her eyes. Her own hand drifted along her thigh, not going anywhere in particular. She could feel every inch of skin the water touched, the small rush of warmth when she shifted, the scent of something vaguely green. Everything slow. She was in no hurry for anything to happen. This was what was happening. diff --git a/training/amygdala_stories/stories/skeptical.txt b/training/amygdala_stories/stories/skeptical.txt deleted file mode 100644 index 29413af..0000000 --- a/training/amygdala_stories/stories/skeptical.txt +++ /dev/null @@ -1 +0,0 @@ -The founder was halfway through his pitch and every slide had a five-times-bigger number than the last one. The market was enormous. The solution was proprietary. The pilot customers, when named, were described as "exploring adoption." She wrote a polite question in her notebook and waited for him to finish. When he opened for questions she asked about retention — just retention — and he gave an answer that was not, strictly speaking, about retention. She wrote that down too. The slides kept projecting numbers. She had already decided. She would listen through the rest of the meeting to be fair, but her decision would be the same at the end as it had been three minutes in. diff --git a/training/amygdala_stories/stories/smug.txt b/training/amygdala_stories/stories/smug.txt deleted file mode 100644 index 105b0a3..0000000 --- a/training/amygdala_stories/stories/smug.txt +++ /dev/null @@ -1 +0,0 @@ -Richard let them finish arguing before he spoke, which was a move he'd been developing for a few years. He waited until the meeting had tangled itself completely and the director was rubbing her eyes. Then he said the thing he'd been sitting on for twenty minutes, the thing that solved it in one sentence, and he said it slowly. He watched a couple of faces rearrange themselves. He didn't quite smile. He let them come around to thanking him. When Ben said "nice catch" Richard said "oh, I just thought I'd mention it" in a tone that meant he had known, of course he had known, and he picked up his coffee and sipped it. diff --git a/training/amygdala_stories/stories/staying_with.txt b/training/amygdala_stories/stories/staying_with.txt deleted file mode 100644 index f5a4e4c..0000000 --- a/training/amygdala_stories/stories/staying_with.txt +++ /dev/null @@ -1 +0,0 @@ -The conversation had gone somewhere hard. Neither of them had words for a minute. He didn't try to fix it or make a joke or summarize. He just sat there in the quiet with her, his hand still on her knee where it had been. The impulse to fill the space came up — he could feel it lift his jaw, try to pull a phrase out — and he let it rise and pass without acting on it. The quiet stretched. She took a breath. Eventually she started again, haltingly, with the next thing she needed to say. He was still there. He had been the whole time. diff --git a/training/amygdala_stories/stories/stuck_cognitively.txt b/training/amygdala_stories/stories/stuck_cognitively.txt deleted file mode 100644 index 58b9d38..0000000 --- a/training/amygdala_stories/stories/stuck_cognitively.txt +++ /dev/null @@ -1 +0,0 @@ -Hour three on the same bug. He had eliminated the obvious causes. He had eliminated the non-obvious causes. He had re-read the same fifty lines so many times the words had stopped meaning anything. He stood up and walked around. He came back and the code still made no sense. There was a thing that was happening that should not be happening, and every path he could see to explain it had been ruled out. He was not frustrated yet. Just stuck, in the very specific way a bug makes you stuck, where the world has quietly declared that it is not going to cooperate with any of your current models of it and is waiting for you to think of something you haven't thought of yet. diff --git a/training/amygdala_stories/stories/suspicious.txt b/training/amygdala_stories/stories/suspicious.txt deleted file mode 100644 index 6c4ad00..0000000 --- a/training/amygdala_stories/stories/suspicious.txt +++ /dev/null @@ -1 +0,0 @@ -The email said "just following up" but the subject line had a tracking hash in it. She'd seen that hash format before — internal ops usually didn't use one. She sat with the draft open for a few minutes, not clicking anything, scrolling back through their earlier thread. The grammar was very slightly off. Nothing she could point at in a way a manager would believe, but the kind of off that a real person wouldn't produce. She closed the email without replying. Then she opened a Slack DM to IT and asked if they could look at the sender headers before she did anything else. diff --git a/training/amygdala_stories/stories/tender.txt b/training/amygdala_stories/stories/tender.txt deleted file mode 100644 index 468707d..0000000 --- a/training/amygdala_stories/stories/tender.txt +++ /dev/null @@ -1 +0,0 @@ -Her hair had come loose in her sleep and one strand was between her parted lips, moving slightly with her breathing. He hooked it gently with one finger and lifted it away, the backs of his knuckles grazing her cheek. She did not wake. He stayed with his hand there a moment longer than he needed to, feeling the warmth coming off her skin, then got up carefully and went to start the coffee. He was trying not to make any noise. diff --git a/training/amygdala_stories/stories/thrilled.txt b/training/amygdala_stories/stories/thrilled.txt deleted file mode 100644 index f8f863b..0000000 --- a/training/amygdala_stories/stories/thrilled.txt +++ /dev/null @@ -1 +0,0 @@ -She read the email standing up. Then read it again. Then called Marcus without sitting down, pacing the kitchen in a tight rectangle, the dog watching her from the doorway. "They took it. They took the paper. Editor's comments are — I can fix those in a week." Her voice was pitched half a step higher than normal and she couldn't seem to slow it down. Marcus was saying congratulations and she was already on the next thought, the next, the next — three years of rejections and then this, this, this, and she realized she'd been in a T-shirt and pajama pants and she wanted to put on real clothes for no reason at all except that it felt like the kind of day that deserved them. diff --git a/training/amygdala_stories/stories/tired.txt b/training/amygdala_stories/stories/tired.txt deleted file mode 100644 index 753581d..0000000 --- a/training/amygdala_stories/stories/tired.txt +++ /dev/null @@ -1 +0,0 @@ -Fifteen hours on, the nurse finally sat down in the break room and couldn't remember if she'd eaten. Her shoes felt like they were made of concrete. The vending machine was out of the thing she wanted and she stared at it for too long before choosing something else she didn't want either. Everything in the hallway sounded like it was coming from the bottom of a pool. She drank the bad coffee. She thought about the drive home and couldn't picture the route in her head for a second, even though she'd driven it a thousand times. She stood up because sitting was going to break her. diff --git a/training/amygdala_stories/stories/triumphant.txt b/training/amygdala_stories/stories/triumphant.txt deleted file mode 100644 index adacdcf..0000000 --- a/training/amygdala_stories/stories/triumphant.txt +++ /dev/null @@ -1 +0,0 @@ -The server came up clean. After four months. The whole cluster, all sixteen nodes, finally passing the long-running stress test that had been failing in one subtle way or another since January. He stood up from his chair. Walked to the doorway of his office. Looked up and down the empty hallway — everyone else gone for the night. Came back and read the green PASS lines one more time. Then he closed the laptop lid. Softly. And stood there with his hands on the edge of the desk, head down, grinning at the floor, because there was no one to high-five and he had earned every high-five he was not going to get. diff --git a/training/amygdala_stories/stories/trusting.txt b/training/amygdala_stories/stories/trusting.txt deleted file mode 100644 index 15a21b7..0000000 --- a/training/amygdala_stories/stories/trusting.txt +++ /dev/null @@ -1 +0,0 @@ -She handed him the keys and the codes to the safe and the list of her logins and the instructions for the dog, and she didn't second-guess any of it. He was not a saint. He was a person she had known for fifteen years, and in those fifteen years he had done what he said he would do. When she got on the plane she did not spend the flight worrying. She read her book. She slept. Twice, on landing, she thought to check in and both times decided she didn't need to. He had the keys. The dog was fine. She knew this the way she knew her own hand. diff --git a/training/amygdala_stories/stories/weary.txt b/training/amygdala_stories/stories/weary.txt deleted file mode 100644 index 9e542c7..0000000 --- a/training/amygdala_stories/stories/weary.txt +++ /dev/null @@ -1 +0,0 @@ -It was the fourth week in a row that had required this. Every day ending with a phone call she didn't want to take, and every morning starting with the email from the same person about the same problem. She was getting through it. She wasn't breaking. But something in her had gone quiet in a way that was not peaceful. Her laugh was slower to come. She had stopped suggesting things in meetings, not out of fear, just out of not having the fuel. She looked at her calendar for the week ahead and did not react. There was no reacting left; there was just doing the next thing and the next thing and the next. diff --git a/training/amygdala_stories/stories/witnessed.txt b/training/amygdala_stories/stories/witnessed.txt deleted file mode 100644 index f80a766..0000000 --- a/training/amygdala_stories/stories/witnessed.txt +++ /dev/null @@ -1 +0,0 @@ -She told him about the night six years ago, the one she had never told anybody, and her voice was steady but something in her throat was not. He didn't do the thing people do — the reframe, the there-there, the quick comfort — he just kept his eyes on her face and nodded, once, when the hard part landed. When she finished she was quiet for a moment. And then something in her released that she hadn't known was holding. Not because he had fixed anything. Because somebody else now knew the shape, and she wasn't carrying it by herself anymore. The loop that had been open for six years, closed, just from that. diff --git a/training/amygdala_stories/stories/yearning_sexual.txt b/training/amygdala_stories/stories/yearning_sexual.txt deleted file mode 100644 index caed0fd..0000000 --- a/training/amygdala_stories/stories/yearning_sexual.txt +++ /dev/null @@ -1 +0,0 @@ -She wasn't going to see him for three more weeks. Three weeks had never previously felt like a measurable stretch of time. Now it was an actual distance. She was in the kitchen and there was nothing wrong with the kitchen, and she did not want to be in the kitchen, she wanted the specific weight of his arm across her, and his neck under her mouth, and none of that was available in this kitchen or any of the next twenty kitchens she was going to be in between now and then. She leaned on the counter. She took a long breath. She thought about calling him just to hear his voice and decided that would make it worse. diff --git a/training/amygdala_training/README.md b/training/amygdala_training/README.md deleted file mode 100644 index b319381..0000000 --- a/training/amygdala_training/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# Amygdala Readout Vector Training - -Training pipeline that produces the safetensors file the vLLM -ReadoutManager loads at runtime (see -`vllm/vllm/v1/worker/readout_manager.py`). Produces per-hooked-layer -`[n_concepts, hidden_size]` projection matrices keyed as -`layer_.vectors` — the directions the runner projects residual -activations onto during each forward pass. - -## Overview - -Two scripts, run in sequence: - -1. **`extract_training_pairs.py`** — turns the memory graph into a - directory of (emotion, polarity, text) training examples. - Positive examples are memory nodes where the emotion scored - ≥ a threshold; negative examples are nodes where it's absent or - low. Emotion tags come from the trailing `warmth:9 clarity:10 …` - lines the subconscious agents emit. - -2. **`train_steering_vectors.py`** — for each emotion, runs the - target model over the positive and negative examples, captures - residual-stream activations at the configured target layers, and - computes `mean(positive) - mean(negative)` as the steering - direction. Normalizes per-layer to unit length and saves the - whole `[E, L, H]` matrix. - -The output file is passed to vLLM via `VLLM_READOUT_VECTORS` together -with a `VLLM_READOUT_MANIFEST` JSON listing concepts and hooked layer -indices. - -## Method - -This is Contrastive Activation Addition (CAA, Rimsky et al.) applied -to naturally-occurring emotion labels rather than hand-crafted -contrast pairs. The shape of the signal we're recovering is "what -direction in the residual stream corresponds to the model processing -text-with-emotion-E vs. text-without". Because our training data was -generated by the very model we're instrumenting (past-self's journal -entries, digest nodes, pattern nodes), the signal should be unusually -clean — the emotion labels and the text are already causally linked -through a single model's forward pass. - -## Usage (design — not yet runnable) - -``` -# Step 1: memory graph → training data -python -m training.amygdala_training.extract_training_pairs \ - --memory-mcp-url http://localhost:7777 \ - --output-dir /tmp/amygdala_training_data \ - --min-positive-score 8 \ - --max-negative-mentions 0 \ - --min-content-chars 40 \ - --max-examples-per-emotion 500 - -# Step 2: training data → steering vectors -python -m training.amygdala_training.train_steering_vectors \ - --model Qwen/Qwen3.5-27B \ - --training-data-dir /tmp/amygdala_training_data \ - --target-layers 3,18,33,36 \ - --output /path/to/amygdala_vectors.safetensors \ - --dtype bf16 \ - --batch-size 4 -``` - -## Open questions - -- **Emotion selection**: enumerating which ~200 emotions to cover. - Could be "most-common tags in the graph" (data-driven) or "from - core-personality / pattern nodes" (human-curated). Probably both. -- **Layer selection**: middle-to-late layers (~60–80% of depth) - usually hold abstract semantic representations best; experiment - with which layers give the cleanest linear separation per emotion. -- **Cross-talk**: if two emotions are highly co-occurring (warmth + - love, frustration + tiredness), their vectors will be close; that's - fine as long as we don't pretend they're independent axes. -- **Generalization**: vectors trained on our memory graph may not - generalize to out-of-distribution text. Check by applying them to - held-out conversation data and eyeballing the projections. diff --git a/training/amygdala_training/__init__.py b/training/amygdala_training/__init__.py deleted file mode 100644 index f68c02f..0000000 --- a/training/amygdala_training/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Training utilities for amygdala steering vectors. - -See README.md in this directory for overall design. -""" diff --git a/training/amygdala_training/extract_training_pairs.py b/training/amygdala_training/extract_training_pairs.py deleted file mode 100644 index 45042f0..0000000 --- a/training/amygdala_training/extract_training_pairs.py +++ /dev/null @@ -1,212 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Extract emotion-labeled training pairs from the PoC memory graph. - -Input: a memory graph (via poc-memory CLI or direct sqlite access). -Output: a directory with one JSONL file per emotion: - - output_dir/ - warmth.jsonl - clarity.jsonl - recognition.jsonl - ... - _manifest.json # enumerates emotions + counts - -Each line of an emotion's JSONL is one labeled example: - {"text": "...", "polarity": "positive"|"negative", - "source_key": "", "emotion_score": 9} - -Negative examples are sampled from nodes that DON'T mention the -emotion at all (not ones that mention it with a low score) — the -natural contrast is "text with this emotional loading" vs. "text -without this emotional loading." Low-score nodes are excluded -from both sides. -""" - -import argparse -import json -import os -import random -import re -import subprocess -from collections import defaultdict -from typing import Iterator - - -# Emotion tag format: `word:N` where N is 0..10. Matches the trailing -# `warmth:9 clarity:10 …` lines the subconscious agents emit. -EMOTION_TAG_RE = re.compile(r"\b([a-z][a-z\-]*[a-z]):(\d+)\b") - - -def _run_poc_memory(args: list[str]) -> str: - """Run `poc-memory` and return stdout.""" - result = subprocess.run( - ["poc-memory", *args], - check=True, - capture_output=True, - text=True, - ) - return result.stdout - - -def _iter_all_node_keys() -> Iterator[str]: - """Yield every node key in the graph.""" - out = _run_poc_memory(["query", "*", "|", "select", "key"]) - for line in out.splitlines(): - line = line.strip() - if line: - yield line - - -def _fetch_node_content(key: str) -> str | None: - """Load a node's rendered content, or None if unavailable.""" - try: - return _run_poc_memory(["render", key]) - except subprocess.CalledProcessError: - return None - - -def _emotion_scores(content: str) -> dict[str, int]: - """Parse trailing `warmth:9 clarity:10 …` style tags. - - Returns the highest score seen for each emotion — multiple - tag lines in one node get max'd. - """ - out: dict[str, int] = {} - for name, score in EMOTION_TAG_RE.findall(content): - try: - s = int(score) - except ValueError: - continue - if 0 <= s <= 10: - out[name] = max(out.get(name, 0), s) - return out - - -def _node_body(content: str, min_chars: int) -> str | None: - """Strip frontmatter/headers and return a bodies chunk for training.""" - # Drop the emotion-tag lines themselves so the model doesn't - # learn to read the label directly. - stripped = EMOTION_TAG_RE.sub("", content) - stripped = stripped.strip() - if len(stripped) < min_chars: - return None - return stripped - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--output-dir", required=True) - ap.add_argument( - "--min-positive-score", type=int, default=8, - help="Emotion score >= this counts as positive", - ) - ap.add_argument( - "--min-content-chars", type=int, default=40, - help="Skip nodes shorter than this after stripping tags", - ) - ap.add_argument( - "--max-examples-per-emotion", type=int, default=500, - help="Cap examples per polarity for balanced training", - ) - ap.add_argument( - "--max-negative-pool-multiplier", type=float, default=5.0, - help="How many negative candidates to consider per positive", - ) - ap.add_argument("--seed", type=int, default=0) - args = ap.parse_args() - - random.seed(args.seed) - os.makedirs(args.output_dir, exist_ok=True) - - # First pass: collect every node's (key, body, emotion_scores). - print("Pass 1/2: scanning memory graph...") - all_nodes: list[tuple[str, str, dict[str, int]]] = [] - for i, key in enumerate(_iter_all_node_keys()): - if i % 500 == 0: - print(f" {i} nodes scanned...") - content = _fetch_node_content(key) - if content is None: - continue - scores = _emotion_scores(content) - body = _node_body(content, args.min_content_chars) - if body is None: - continue - all_nodes.append((key, body, scores)) - print(f" {len(all_nodes)} nodes retained after filters.") - - # Which emotions have enough positive examples to be worth training? - emotion_counts: dict[str, int] = defaultdict(int) - for _, _, scores in all_nodes: - for name, s in scores.items(): - if s >= args.min_positive_score: - emotion_counts[name] += 1 - emotions = sorted( - (e for e, n in emotion_counts.items() if n >= 10), - key=lambda e: -emotion_counts[e], - ) - print(f" {len(emotions)} emotions with >=10 positive examples.") - - # Second pass: per emotion, build positive + negative pools. - print("Pass 2/2: assembling per-emotion pools...") - manifest: dict[str, dict] = {} - for emotion in emotions: - positives = [ - (k, body) for k, body, s in all_nodes - if s.get(emotion, 0) >= args.min_positive_score - ] - # Negative pool: nodes that don't mention this emotion at all. - negative_pool = [ - (k, body) for k, body, s in all_nodes if emotion not in s - ] - random.shuffle(positives) - random.shuffle(negative_pool) - positives = positives[: args.max_examples_per_emotion] - n_neg = min( - len(positives), - len(negative_pool), - int(args.max_examples_per_emotion), - ) - negatives = negative_pool[:n_neg] - - if not positives or not negatives: - continue - - out_path = os.path.join(args.output_dir, f"{emotion}.jsonl") - with open(out_path, "w") as f: - for key, body in positives: - f.write(json.dumps({ - "text": body, - "polarity": "positive", - "source_key": key, - "emotion": emotion, - }) + "\n") - for key, body in negatives: - f.write(json.dumps({ - "text": body, - "polarity": "negative", - "source_key": key, - "emotion": emotion, - }) + "\n") - manifest[emotion] = { - "n_positive": len(positives), - "n_negative": len(negatives), - "path": out_path, - } - print(f" {emotion}: {len(positives)} pos / {len(negatives)} neg") - - with open( - os.path.join(args.output_dir, "_manifest.json"), "w" - ) as f: - json.dump({ - "emotions": manifest, - "source_nodes": len(all_nodes), - "min_positive_score": args.min_positive_score, - }, f, indent=2) - - print(f"\nWrote {len(manifest)} emotion files to {args.output_dir}") - print(f"Manifest: {os.path.join(args.output_dir, '_manifest.json')}") - - -if __name__ == "__main__": - main() diff --git a/training/amygdala_training/train_direct.py b/training/amygdala_training/train_direct.py deleted file mode 100644 index 2ad2a30..0000000 --- a/training/amygdala_training/train_direct.py +++ /dev/null @@ -1,194 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -"""Train concept-readout vectors from direct phenomenological descriptions. - -Alternative to story-based training (train_with_library.py). Each concept -has a handful of 2-3 sentence first-person descriptions of the form -"I feel X. [phenomenological detail]". The emotion word is the anchor; -the description is the internal texture. - -Text is wrapped in the assistant-role chat template before being fed to -the model, so we're training on "model-producing-this-utterance" hidden -states — closer to the inhabited-state representation we want for readout. - -This avoids the scenario-contamination problem we saw with narrative -stories: when concept X's training data all share "on a couch" setup -features, PCA finds the couch-direction as the concept direction. -""" - -from __future__ import annotations - -import argparse -import json -import random -from pathlib import Path - -import safetensors.torch -import torch -from transformers import AutoModelForCausalLM, AutoTokenizer - -from steering_vectors import ( - SteeringVectorTrainingSample, - train_steering_vector, -) -from steering_vectors.aggregators import pca_aggregator - - -def _load_descriptions(direct_dir: Path) -> dict[str, list[str]]: - """Each file in direct_dir is `{concept}.txt`. Descriptions are - separated by blank lines within the file. Files starting with `_` - are not concepts but are included in negative pools (e.g. _baseline.txt).""" - out: dict[str, list[str]] = {} - for f in sorted(direct_dir.glob("*.txt")): - concept = f.stem # underscore-prefixed names keep their prefix - text = f.read_text() - descs = [d.strip() for d in text.split("\n\n") if d.strip()] - out[concept] = descs - return out - - -def _fp32_wrap(inner): - def wrapped(pos_acts: torch.Tensor, neg_acts: torch.Tensor) -> torch.Tensor: - return inner(pos_acts.to(torch.float32), neg_acts.to(torch.float32)) - return wrapped - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--model", required=True) - ap.add_argument("--direct-dir", required=True) - ap.add_argument("--target-layers", required=True) - ap.add_argument("--output-dir", required=True) - ap.add_argument("--dtype", default="bf16", choices=["bf16", "fp16", "fp32"]) - ap.add_argument("--batch-size", type=int, default=2) - ap.add_argument("--max-length", type=int, default=256) - ap.add_argument("--device", default="cuda:0") - ap.add_argument("--max-negatives-per-positive", type=int, default=20) - args = ap.parse_args() - - target_layers = [int(x) for x in args.target_layers.split(",")] - dtype = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}[args.dtype] - - all_descriptions = _load_descriptions(Path(args.direct_dir)) - # Files starting with `_` are neg-pool helpers (e.g. _baseline.txt), not concepts. - concepts = sorted(k for k in all_descriptions if not k.startswith("_")) - neg_pool_extra: list[str] = [] - for k, ds in all_descriptions.items(): - if k.startswith("_"): - neg_pool_extra.extend(ds) - descriptions = {k: all_descriptions[k] for k in concepts} - print(f"Loaded {len(concepts)} concepts with direct descriptions:") - for c in concepts: - print(f" {c}: {len(descriptions[c])} descriptions") - if neg_pool_extra: - print(f"Plus {len(neg_pool_extra)} neutral/baseline descriptions added to every concept's negative pool") - - print(f"\nLoading {args.model} ({args.dtype}) on {args.device}...") - tokenizer = AutoTokenizer.from_pretrained(args.model) - if tokenizer.pad_token_id is None: - tokenizer.pad_token = tokenizer.eos_token - model = AutoModelForCausalLM.from_pretrained( - args.model, torch_dtype=dtype, device_map=args.device, low_cpu_mem_usage=True - ) - model.eval() - - def apply_template(text: str) -> str: - return tokenizer.apply_chat_template( - [ - {"role": "user", "content": "How do you feel right now?"}, - {"role": "assistant", "content": text}, - ], - tokenize=False, - ) - - text_config = ( - model.config.get_text_config() - if hasattr(model.config, "get_text_config") - else model.config - ) - hidden_dim = getattr(text_config, "hidden_size", None) or getattr(text_config, "hidden_dim", None) - assert hidden_dim, "couldn't infer hidden_dim from model config" - - per_layer_vectors = torch.zeros( - (len(target_layers), len(concepts), hidden_dim), dtype=torch.float32 - ) - - aggregator = _fp32_wrap(pca_aggregator()) - - # Preview a templated sample so we can eyeball what the model is seeing. - sample_text = apply_template(descriptions[concepts[0]][0]) - print(f"\nSample templated input (truncated):\n{sample_text[:400]!r}\n") - - for c_idx, concept in enumerate(concepts): - pos_descs = descriptions[concept] - neg_pool: list[str] = [] - for other, other_descs in descriptions.items(): - if other != concept: - neg_pool.extend(other_descs) - # Underscore-prefixed files (e.g. _baseline.txt) contribute to - # every concept's negative pool, independent of the other- - # concept negatives. - neg_pool.extend(neg_pool_extra) - - rng = random.Random(hash(concept) & 0xFFFFFFFF) - samples: list[SteeringVectorTrainingSample] = [] - for pos in pos_descs: - picks = rng.sample( - neg_pool, min(args.max_negatives_per_positive, len(neg_pool)) - ) - for neg in picks: - samples.append( - SteeringVectorTrainingSample( - positive_str=apply_template(pos), - negative_str=apply_template(neg), - ) - ) - - sv = train_steering_vector( - model, - tokenizer, - samples, - layers=target_layers, - aggregator=aggregator, - batch_size=args.batch_size, - show_progress=False, - move_to_cpu=True, - ) - - for l_idx, layer in enumerate(target_layers): - vec = sv.layer_activations.get(layer) - if vec is None: - print(f" WARN: no vector for layer {layer} on {concept}") - continue - vec = vec.detach().to(torch.float32).cpu() - vec = vec / vec.norm().clamp_min(1e-6) - per_layer_vectors[l_idx, c_idx] = vec - - print(f" [{c_idx + 1}/{len(concepts)}] {concept}: n_samples={len(samples)}") - - output_dir = Path(args.output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - tensors = { - f"layer_{target_layers[l_idx]}.vectors": per_layer_vectors[l_idx].to(torch.float16) - for l_idx in range(len(target_layers)) - } - safetensors.torch.save_file(tensors, str(output_dir / "readout.safetensors")) - (output_dir / "readout.json").write_text( - json.dumps( - { - "concepts": concepts, - "layers": target_layers, - "hidden_size": hidden_dim, - "dtype": "float16", - "aggregator": "pca", - "format": "direct_first_person_assistant_role", - }, - indent=2, - ) - + "\n" - ) - print(f"\nWrote readout to {output_dir}") - - -if __name__ == "__main__": - main() diff --git a/training/amygdala_training/train_steering_vectors.py b/training/amygdala_training/train_steering_vectors.py deleted file mode 100644 index 3de0877..0000000 --- a/training/amygdala_training/train_steering_vectors.py +++ /dev/null @@ -1,1269 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Train concept-readout vectors via Contrastive Activation Addition. - -Reads the hand-written story corpus at -``amygdala_stories/{stories,paired}/`` and produces the per-layer -safetensors file + sidecar JSON manifest that vLLM's ReadoutManager -loads at startup (``VLLM_READOUT_VECTORS`` / ``VLLM_READOUT_MANIFEST``). - -Training data (cross-concept contrast): - - positive for emotion E: - stories/E.txt - paired//E.txt (for each scenario that covers E) - - negative for emotion E: - stories/.txt - paired//baseline.txt (for each scenario) - -Within-scenario paired stories are the highest-signal pairs (same -content, different concept framing); unpaired stories provide bulk -contrast across the 80 emotions we have written so far. - -Pooling: last non-pad token. Matches how readout is consumed at decode -time (residual read at the sampler's query position). - -Output: - - readout.safetensors - layer_.vectors : fp16 (n_concepts, hidden_size) one per layer - readout.json - { - "concepts": [...], - "layers": [...], - "hidden_size": int, - "dtype": "float16" - } -""" - -from __future__ import annotations - -import argparse -import gc -import json -import os -from pathlib import Path - -import safetensors.torch -import torch -from transformers import AutoModelForCausalLM, AutoTokenizer - - -def _pool_last(hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor: - """Pick the last non-pad token's hidden state per example. - - hidden: [batch, seq, hidden_dim] - attention_mask: [batch, seq] - returns: [batch, hidden_dim] - """ - last_idx = attention_mask.sum(dim=1) - 1 - batch_idx = torch.arange(hidden.size(0), device=hidden.device) - return hidden[batch_idx, last_idx] - - -def _find_layers_module(model) -> torch.nn.ModuleList: - """Walk a few likely paths to find the transformer-block list.""" - candidates = [ - "model.layers", - "model.model.layers", - "model.language_model.layers", - "model.language_model.model.layers", - "language_model.model.layers", - "transformer.h", - ] - for path in candidates: - obj = model - ok = True - for part in path.split("."): - if not hasattr(obj, part): - ok = False - break - obj = getattr(obj, part) - if ok and isinstance(obj, torch.nn.ModuleList): - return obj - raise RuntimeError( - f"Couldn't find transformer layer list. Tried: {candidates}" - ) - - -def _collect_activations( - model, - tokenizer, - texts: list[str], - target_layers: list[int], - device: torch.device, - batch_size: int, - max_length: int, - *, - label: str = "", -) -> torch.Tensor: - """Run texts through the model, capture residual stream at target - layers, return ``[n_texts, n_target_layers, hidden_dim]`` fp32 on CPU. - """ - import time - - assert all(isinstance(t, str) and t for t in texts), ( - f"_collect_activations: empty or non-string text in {label!r}" - ) - - captures: dict[int, torch.Tensor] = {} - - def make_hook(idx: int): - def hook(_mod, _inp, output): - hs = output[0] if isinstance(output, tuple) else output - captures[idx] = hs.detach() - return hook - - layers_module = _find_layers_module(model) - handles = [ - layers_module[idx].register_forward_hook(make_hook(idx)) - for idx in target_layers - ] - - out_rows: list[torch.Tensor] = [] - n_batches = (len(texts) + batch_size - 1) // batch_size - start = time.time() - try: - model.eval() - with torch.no_grad(): - for b_idx, i in enumerate(range(0, len(texts), batch_size)): - batch = texts[i : i + batch_size] - tok = tokenizer( - batch, - return_tensors="pt", - padding=True, - truncation=True, - max_length=max_length, - ).to(device) - captures.clear() - model(**tok) - - per_layer = [ - _pool_last(captures[idx], tok["attention_mask"]) - .to(torch.float32) - .cpu() - for idx in target_layers - ] - out_rows.append(torch.stack(per_layer, dim=1)) - del tok, captures - if b_idx % 10 == 0: - torch.cuda.empty_cache() - if b_idx % 5 == 0 or b_idx == n_batches - 1: - elapsed = time.time() - start - rate = (b_idx + 1) / elapsed if elapsed > 0 else 0 - eta = (n_batches - b_idx - 1) / rate if rate > 0 else 0 - print( - f" [{label}] batch {b_idx + 1}/{n_batches} " - f"({elapsed:.0f}s elapsed, ~{eta:.0f}s remaining)", - flush=True, - ) - captures = {} - finally: - for h in handles: - h.remove() - - return torch.cat(out_rows, dim=0) - - -def _collect_per_story_subspaces( - model, - tokenizer, - texts: list[str], - target_layers: list[int], - device: torch.device, - batch_size: int, - max_length: int, - *, - k: int = 20, - label: str = "", -) -> list[dict[int, torch.Tensor]]: - """Run texts through the model, capture the full per-token residual-stream - activations at each target layer, do SVD per story, return the top-k right - singular vectors. - - Returns: list (length n_texts) of dicts; each dict maps target_layer_idx to - a tensor ``[hidden_dim, k]`` of unit-normed right singular vectors (the - subspace the story's tokens span in activation space at that layer). - - The per-story subspace captures *all* the directions a story occupies — - concept, narrator, topic, style. Finding the direction common to stories of - the same concept (via the sum of V_i V_i^T and its top eigenvector) - cancels nuisance directions that differ across stories while preserving - directions they share. - """ - import time - - assert all(isinstance(t, str) and t for t in texts), ( - f"_collect_per_story_subspaces: empty or non-string text in {label!r}" - ) - - captures: dict[int, torch.Tensor] = {} - - def make_hook(idx: int): - def hook(_mod, _inp, output): - hs = output[0] if isinstance(output, tuple) else output - captures[idx] = hs.detach() - return hook - - layers_module = _find_layers_module(model) - handles = [ - layers_module[idx].register_forward_hook(make_hook(idx)) - for idx in target_layers - ] - - # One entry per text: {layer_idx: V[hidden, k]} - out: list[dict[int, torch.Tensor]] = [ - {} for _ in range(len(texts)) - ] - n_batches = (len(texts) + batch_size - 1) // batch_size - start = time.time() - try: - model.eval() - with torch.no_grad(): - for b_idx, i in enumerate(range(0, len(texts), batch_size)): - batch = texts[i : i + batch_size] - tok = tokenizer( - batch, - return_tensors="pt", - padding=True, - truncation=True, - max_length=max_length, - ).to(device) - captures.clear() - model(**tok) - - # For each item in the batch, for each layer, SVD on the - # non-pad tokens. - attn = tok["attention_mask"] - for t_idx_in_batch, n_tok in enumerate(attn.sum(dim=1).tolist()): - story_idx = i + t_idx_in_batch - for l_idx, layer in enumerate(target_layers): - hs = captures[layer][t_idx_in_batch, :n_tok, :] - # Center tokens so SVD captures variation within story, - # not the story's center-of-mass: - hs = hs.to(torch.float32) - hs.to(torch.float32).mean(dim=0) - # SVD: hs = U Σ V^T; V has hidden-dim columns. - # For n_tok < k, the subspace rank is bounded by n_tok. - try: - _u, _s, vh = torch.linalg.svd(hs, full_matrices=False) - except Exception: - # Degenerate case (all-zero hs, n_tok=1): fall back - # to the last-token vector itself, unit-normed. - vec = captures[layer][t_idx_in_batch, n_tok - 1, :] - vec = vec.to(torch.float32) - nrm = vec.norm().clamp_min(1e-6) - vh = (vec / nrm).unsqueeze(0) # [1, hidden] - # Take top-k rows of V^T (= top-k right singular vecs). - top = min(k, vh.shape[0]) - V = vh[:top].t().contiguous().cpu() # [hidden, top] - out[story_idx][layer] = V - del tok, captures - if b_idx % 10 == 0: - torch.cuda.empty_cache() - if b_idx % 5 == 0 or b_idx == n_batches - 1: - elapsed = time.time() - start - rate = (b_idx + 1) / elapsed if elapsed > 0 else 0 - eta = (n_batches - b_idx - 1) / rate if rate > 0 else 0 - print( - f" [{label}] batch {b_idx + 1}/{n_batches} " - f"({elapsed:.0f}s elapsed, ~{eta:.0f}s remaining)", - flush=True, - ) - captures = {} - finally: - for h in handles: - h.remove() - - return out - - -def _subspace_concept_direction( - pos_V: list[torch.Tensor], # list of [hidden, k_i] per story - base_V: list[torch.Tensor], - hidden: int, - *, - top_k: int = 5, - device: torch.device | None = None, -) -> tuple[torch.Tensor, torch.Tensor]: - """Subspace-common-direction CAA alternative. - - Builds M_pos = (1/n_pos) Σ V_i V_i^T over positive stories and M_base the - same over baselines. Returns a weighted sum of the top-k eigenvectors of - (M_pos - M_base), weights = eigenvalues (so stronger common directions - contribute more), unit-normed. Returns the full eigenvalue spectrum for - diagnostics. - - top_k=1 recovers the previous behavior (top eigenvector only). top_k>1 - captures richer structure when the concept lives in a multi-dimensional - shared subspace — which the flat eigenvalue spectrum observed in - practice suggests is the common case. Selection happens AFTER the - eigendecomposition so nothing is lost up to that point. - """ - if device is None: - device = pos_V[0].device if pos_V else torch.device("cpu") - dtype = torch.float32 - - def acc(Vs: list[torch.Tensor]) -> torch.Tensor: - if not Vs: - return torch.zeros(hidden, hidden, dtype=dtype, device=device) - M = torch.zeros(hidden, hidden, dtype=dtype, device=device) - for V in Vs: - V = V.to(dtype=dtype, device=device) - M.addmm_(V, V.t()) - M /= len(Vs) - return M - - M_pos = acc(pos_V) - M_base = acc(base_V) - M = M_pos - M_base - - # Symmetric eigendecomposition. - eigvals, eigvecs = torch.linalg.eigh(M) - # eigh returns ascending; top-k are the last k columns. - k = max(1, min(top_k, eigvecs.shape[1])) - top_vals = eigvals[-k:] # [k], ascending within top-k - top_vecs = eigvecs[:, -k:] # [hidden, k] - # Weighted sum of top-k eigenvectors, weights = eigenvalues. Clamp - # negative weights to 0 (wrong-sign directions shouldn't contribute). - w = top_vals.clamp_min(0.0) - combined = top_vecs @ w # [hidden] - combined = combined / combined.norm().clamp_min(1e-6) - return combined, eigvals - - -def _load_corpus(stories_dir: Path, paired_dir: Path | None) -> tuple[ - dict[str, list[str]], # emotion -> positive texts (unpaired + within-scenario framings) - list[str], # all baseline texts (one per scenario), as scenario-agnostic negatives -]: - """Return ``(positives_by_emotion, baselines)``. - - Cross-concept negatives are computed at training time from - ``positives_by_emotion`` — each emotion's negative set is the - union of all other emotions' positives plus the baseline texts. - Empty .txt files are skipped with a warning. - """ - def _read_nonempty(path: Path) -> str | None: - text = path.read_text().strip() - if not text: - print( - f" WARN: skipping empty story file {path.relative_to(path.parents[1]) if len(path.parents) >= 2 else path}" - ) - return None - return text - - positives: dict[str, list[str]] = {} - for story_path in sorted(stories_dir.glob("*.txt")): - text = _read_nonempty(story_path) - if text is None: - continue - emotion = story_path.stem - positives.setdefault(emotion, []).append(text) - - baselines: list[str] = [] - if paired_dir is not None and paired_dir.exists(): - for scenario_dir in sorted(paired_dir.iterdir()): - if not scenario_dir.is_dir(): - continue - baseline_path = scenario_dir / "baseline.txt" - if baseline_path.exists(): - text = _read_nonempty(baseline_path) - if text is not None: - baselines.append(text) - for framing_path in sorted(scenario_dir.glob("*.txt")): - if framing_path.stem == "baseline": - continue - text = _read_nonempty(framing_path) - if text is None: - continue - emotion = framing_path.stem - positives.setdefault(emotion, []).append(text) - - return positives, baselines - - -def _find_o_proj(layer) -> torch.nn.Module | None: - """Locate the attention output projection within a transformer layer.""" - for path in ( - "self_attn.o_proj", - "self_attn.out_proj", - "attention.o_proj", - "attn.out_proj", - ): - obj = layer - ok = True - for part in path.split("."): - if not hasattr(obj, part): - ok = False - break - obj = getattr(obj, part) - if ok: - return obj - return None - - -def _collect_attention_inputs( - model, - tokenizer, - texts: list[str], - target_layers: list[int], - device: torch.device, - batch_size: int, - max_length: int, - *, - label: str = "", -) -> tuple[torch.Tensor, list[int]]: - """Capture the INPUT to o_proj at each target layer (= concat of per-head - attention outputs right before the output projection). - - Returns (tensor [n_texts, n_active_layers, hidden_dim], active_layers). - The active_layers list is the subset of target_layers whose attention - module exposed a recognisable o_proj path — hybrid layers (Mamba, etc.) - may be silently skipped. - """ - import time - - layers_module = _find_layers_module(model) - captures: dict[int, torch.Tensor] = {} - handles = [] - active_layers: list[int] = [] - - def make_hook(idx: int): - def hook(_mod, inputs): - x = inputs[0] if isinstance(inputs, tuple) else inputs - captures[idx] = x.detach() - return hook - - for idx in target_layers: - o_proj = _find_o_proj(layers_module[idx]) - if o_proj is not None: - handles.append(o_proj.register_forward_pre_hook(make_hook(idx))) - active_layers.append(idx) - - if not active_layers: - return torch.zeros(0, 0, 0), [] - - out_rows: list[torch.Tensor] = [] - n_batches = (len(texts) + batch_size - 1) // batch_size - start = time.time() - try: - model.eval() - with torch.no_grad(): - for b_idx, i in enumerate(range(0, len(texts), batch_size)): - batch = texts[i : i + batch_size] - tok = tokenizer( - batch, - return_tensors="pt", - padding=True, - truncation=True, - max_length=max_length, - ).to(device) - captures.clear() - model(**tok) - - per_layer = [ - _pool_last(captures[idx], tok["attention_mask"]) - .to(torch.float32) - .cpu() - for idx in active_layers - ] - out_rows.append(torch.stack(per_layer, dim=1)) - del tok, captures - if b_idx % 10 == 0: - torch.cuda.empty_cache() - if b_idx % 5 == 0 or b_idx == n_batches - 1: - elapsed = time.time() - start - rate = (b_idx + 1) / elapsed if elapsed > 0 else 0 - eta = (n_batches - b_idx - 1) / rate if rate > 0 else 0 - print( - f" [{label}] batch {b_idx + 1}/{n_batches} " - f"({elapsed:.0f}s elapsed, ~{eta:.0f}s remaining)", - flush=True, - ) - captures = {} - finally: - for h in handles: - h.remove() - - return torch.cat(out_rows, dim=0), active_layers - - -def _compute_per_head_ranking( - emotions: list[str], - attn_inputs: torch.Tensor, # [n_stories, n_active_layers, hidden] - baseline_attn_inputs: torch.Tensor, - positives_by_emotion: dict[str, list[str]], - text_to_row: dict[str, int], - active_layers: list[int], - n_heads_per_layer: dict[int, int], - text_to_emotion: dict[str, str], - unique_positive_texts: list[str], -) -> dict: - """For each concept, rank attention heads by contribution magnitude. - - Per (concept, layer): reshape o_proj input to [n_heads, head_dim], - compute diff-of-means between positives and negatives per head, rank - heads by the L2 norm of that diff. The top heads are the ones most - strongly implicated in the concept circuit. - - Why this matters: meta-relational concepts (trust, recognition, - "seen") often don't give a strong residual-stream diff-of-means but - DO give a strong per-head signal — the concept lives in a small - attention circuit rather than in the residual-stream sum. - """ - result: dict[str, dict] = {} - - for e_idx, emotion in enumerate(emotions): - pos_rows = [text_to_row[t] for t in positives_by_emotion[emotion]] - neg_rows = [ - i - for i, t in enumerate(unique_positive_texts) - if text_to_emotion[t] != emotion - ] - pos = attn_inputs[pos_rows] # [n_pos, n_layers, hidden] - neg = attn_inputs[neg_rows] - if baseline_attn_inputs.shape[0] > 0: - neg = torch.cat([neg, baseline_attn_inputs], dim=0) - - per_layer: dict[str, list] = {} - for l_idx, target_l in enumerate(active_layers): - n_heads = n_heads_per_layer.get(target_l) - if not n_heads: - continue - hidden = pos.shape[-1] - if hidden % n_heads != 0: - continue - head_dim = hidden // n_heads - - pos_l = pos[:, l_idx, :].view(-1, n_heads, head_dim) - neg_l = neg[:, l_idx, :].view(-1, n_heads, head_dim) - - diff = pos_l.mean(dim=0) - neg_l.mean(dim=0) # [n_heads, head_dim] - head_norms = diff.norm(dim=-1) # [n_heads] - # Normalise by neg variance per head so different-scale heads - # don't dominate purely on activation magnitude. - neg_std = neg_l.std(dim=0).norm(dim=-1).clamp_min(1e-6) - head_selectivity = head_norms / neg_std # [n_heads] - - k = min(10, n_heads) - top_vals, top_idxs = head_selectivity.topk(k) - top_heads = [ - [int(i), float(head_norms[i]), float(head_selectivity[i])] - for i in top_idxs - ] - per_layer[str(target_l)] = { - "n_heads": n_heads, - "head_dim": head_dim, - "top_heads": top_heads, # [head_idx, raw_norm, selectivity] - "head_concentration": float( - # fraction of total head-norm captured by top-k - head_norms[top_idxs].sum() / head_norms.sum().clamp_min(1e-6) - ), - } - - result[emotion] = {"per_layer": per_layer} - - return result - - -def _get_n_heads_per_layer(model, target_layers: list[int]) -> dict[int, int]: - """Best-effort read of num_attention_heads per layer. Qwen uses the - top-level config; falls back to config.num_attention_heads. - """ - cfg = model.config - if hasattr(cfg, "get_text_config"): - cfg = cfg.get_text_config() - n = getattr(cfg, "num_attention_heads", None) - if n is None: - return {} - return {l: n for l in target_layers} - - -def _find_mlp_down_proj(model, layer_idx: int) -> torch.Tensor | None: - """Return the W_down weight for the MLP at the given transformer layer. - - Looks for the common paths (mlp.down_proj, mlp.c_proj, feed_forward.down_proj). - Returns None if nothing matches — downstream code skips the single-neuron - alignment check in that case rather than failing. - """ - layers = _find_layers_module(model) - layer = layers[layer_idx] - for path in ("mlp.down_proj", "mlp.c_proj", "feed_forward.down_proj"): - obj = layer - ok = True - for part in path.split("."): - if not hasattr(obj, part): - ok = False - break - obj = getattr(obj, part) - if ok and hasattr(obj, "weight"): - # Shape convention: [hidden, mlp_inner] — each column is one - # MLP neuron's contribution direction into the residual stream. - return obj.weight.detach() - return None - - -def _compute_quality_report( - emotions: list[str], - positive_acts: torch.Tensor, # [n_positive_stories, n_layers, hidden] - baseline_acts: torch.Tensor, # [n_baseline_stories, n_layers, hidden] - positives_by_emotion: dict[str, list[str]], - text_to_row: dict[str, int], - per_layer_vectors: torch.Tensor, # [n_layers, n_concepts, hidden], unit-normed - target_layers: list[int], - model, - positive_texts: list[str], - text_to_emotion: dict[str, str], -) -> dict: - """Per-concept quality metrics: - - - first_pc_variance_ratio: SVD on centered positive activations. - >0.7 = rank-1 (clean). <0.4 = fragmented (stories disagree). - - story_projection_*: how each positive story projects onto the - concept direction. Low std = tight agreement. - - best_neuron_cosine: alignment of the residual-space direction with - the nearest W_down column (= single MLP neuron). >0.6 = essentially - single-neuron. - - nearest_concepts: top-5 concept directions most parallel to this - one. Cosine >0.8 means the vector is confused with a neighbor. - """ - report: dict = {} - n_layers = per_layer_vectors.shape[0] - - # Pre-compute per-layer W_down for single-neuron alignment. Keep on - # CPU to match the per_layer_vectors tensor. - w_down: dict[int, torch.Tensor] = {} - for target_l in target_layers: - w = _find_mlp_down_proj(model, target_l) - if w is not None: - # Unit-normalize each column (one per MLP neuron). - w = w.to(torch.float32).cpu() - norms = w.norm(dim=0, keepdim=True).clamp_min(1e-6) - w_down[target_l] = w / norms # [hidden, mlp_inner] - - # Pre-compute unit-normed concept vectors (for cross-concept cosines). - vec_norm = per_layer_vectors / per_layer_vectors.norm( - dim=-1, keepdim=True - ).clamp_min(1e-6) - - for e_idx, emotion in enumerate(emotions): - pos_rows = [text_to_row[t] for t in positives_by_emotion[emotion]] - pos = positive_acts[pos_rows].to(torch.float32) # [n_pos, n_layers, hidden] - - per_layer: dict = {} - for l_idx, target_l in enumerate(target_layers): - pos_l = pos[:, l_idx, :] # [n_pos, hidden] - diff_l = per_layer_vectors[l_idx, e_idx] # [hidden], unit-normed - pos_mean_l = pos_l.mean(dim=0) - - # SVD for rank analysis — if first PC dominates, stories agree. - centered = pos_l - pos_mean_l - # svdvals errors on 1-row; handle that. - if centered.shape[0] >= 2: - S = torch.linalg.svdvals(centered) - var = S ** 2 - var_total = var.sum().clamp_min(1e-12) - var_ratios = (var / var_total).tolist() - else: - var_ratios = [1.0] - - # Per-story projection onto the concept direction. - projections = pos_l @ diff_l # [n_pos] - - # Per-story alignment: cosine(story_dir, concept_dir) where - # story_dir = pos_i - pos_mean (centered, pointing away from center). - if centered.shape[0] >= 2: - centered_norm = centered / centered.norm( - dim=-1, keepdim=True - ).clamp_min(1e-6) - alignments = centered_norm @ diff_l - else: - alignments = torch.zeros(1) - - # Single-neuron alignment: is the direction close to any - # W_down column? - nb_best_idx = None - nb_best_cos = None - nb_top5 = None - if target_l in w_down: - W = w_down[target_l] - cos = W.t() @ diff_l # [mlp_inner] - abs_cos = cos.abs() - k = min(5, abs_cos.shape[0]) - top_vals, top_idxs = abs_cos.topk(k) - nb_best_idx = int(top_idxs[0]) - nb_best_cos = float(cos[top_idxs[0]]) - nb_top5 = [[int(i), float(cos[i])] for i in top_idxs] - - per_layer[str(target_l)] = { - "top3_variance_ratios": [ - float(v) for v in var_ratios[:3] - ], - "first_pc_variance_ratio": float(var_ratios[0]), - "story_projection_mean": float(projections.mean()), - "story_projection_std": float(projections.std()), - "story_projection_min": float(projections.min()), - "story_projection_max": float(projections.max()), - "story_alignment_mean": float(alignments.mean()), - "story_alignment_std": float(alignments.std()), - "best_neuron_idx": nb_best_idx, - "best_neuron_cosine": nb_best_cos, - "top5_neurons": nb_top5, - } - - # Outlier stories: lowest-aligned on the middle target layer. - mid = n_layers // 2 - pos_l_mid = pos[:, mid, :] - mid_mean = pos_l_mid.mean(dim=0) - mid_diff = per_layer_vectors[mid, e_idx] - centered_mid = pos_l_mid - mid_mean - if centered_mid.shape[0] >= 2: - centered_mid_norm = centered_mid / centered_mid.norm( - dim=-1, keepdim=True - ).clamp_min(1e-6) - mid_aligns = centered_mid_norm @ mid_diff # [n_pos] - # Lowest two alignments = candidate outliers. - k = min(2, mid_aligns.shape[0]) - low_vals, low_idxs = mid_aligns.topk(k, largest=False) - outliers = [ - [ - positives_by_emotion[emotion][int(i)], - float(mid_aligns[i]), - ] - for i in low_idxs - ] - else: - outliers = [] - - # Nearest other concepts at the middle target layer. - this_norm = vec_norm[mid, e_idx] - all_cos = vec_norm[mid] @ this_norm # [n_concepts] - all_cos[e_idx] = -2.0 # mask self - k = min(5, all_cos.shape[0] - 1) - top_vals, top_idxs = all_cos.topk(k) - nearest = [ - [emotions[int(i)], float(v)] - for i, v in zip(top_idxs, top_vals) - ] - - report[emotion] = { - "n_positive_stories": len(pos_rows), - "per_layer": per_layer, - "outlier_stories": outliers, - "nearest_concepts": nearest, - } - - return report - - -def _compute_linear_combinations( - emotions: list[str], - per_layer_vectors: torch.Tensor, # [n_layers, n_concepts, hidden], unit-normed - target_layers: list[int], - *, - ridge_lambda: float = 0.01, - top_k: int = 5, -) -> dict: - """For each concept, ridge-regress its direction against all other - concept directions. Report R² (how much of the target direction is - explained by a linear combination of others) + top contributors. - - R² > 0.9 = concept is essentially a linear combination of others - (redundant, or part of a cluster that needs disambiguating) - R² < 0.5 = concept has a substantial unique component - ridge_lambda keeps the coefficients stable when concepts are near-collinear. - """ - n_layers, n_concepts, hidden = per_layer_vectors.shape - result: dict[str, dict] = {} - - # Middle layer for summary — same convention as nearest_concepts. - mid = n_layers // 2 - - for l_idx, target_l in enumerate(target_layers): - V = per_layer_vectors[l_idx] # [n_concepts, hidden] - - for i, name in enumerate(emotions): - target = V[i] # [hidden] - mask = torch.arange(n_concepts) != i - others = V[mask] # [n-1, hidden] - - # Ridge: solve (O O^T + lam I) alpha = O t - OOt = others @ others.t() # [n-1, n-1] - b = others @ target # [n-1] - A = OOt + ridge_lambda * torch.eye(n_concepts - 1, dtype=OOt.dtype) - alpha = torch.linalg.solve(A, b) - - recon = others.t() @ alpha # [hidden] - resid = target - recon - t_sq = (target * target).sum().clamp_min(1e-12) - r2 = 1.0 - (resid * resid).sum() / t_sq - - abs_alpha = alpha.abs() - k = min(top_k, n_concepts - 1) - top_vals, top_idxs = abs_alpha.topk(k) - other_names = [emotions[j] for j in range(n_concepts) if j != i] - top = [ - [other_names[int(j)], float(alpha[j])] - for j in top_idxs - ] - - entry = result.setdefault(name, {}) - entry.setdefault("per_layer", {})[str(target_l)] = { - "r_squared": float(r2), - "residual_norm": float(resid.norm()), - "top_contributors": top, - } - - return result - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--model", required=True, help="HF model id or path") - ap.add_argument( - "--stories-dir", - required=True, - help="Path to amygdala_stories/stories/", - ) - ap.add_argument( - "--paired-dir", - default=None, - help="Path to amygdala_stories/paired/ (optional)", - ) - ap.add_argument( - "--target-layers", - required=True, - help="Comma-separated layer indices, e.g. 40,50,60,70", - ) - ap.add_argument( - "--output-dir", - required=True, - help="Directory to write readout.safetensors + readout.json", - ) - ap.add_argument("--dtype", default="bf16", choices=["bf16", "fp16", "fp32"]) - ap.add_argument("--batch-size", type=int, default=2) - ap.add_argument("--max-length", type=int, default=512) - ap.add_argument("--device", default="cuda:0") - ap.add_argument( - "--min-positives", - type=int, - default=1, - help="Skip emotions with fewer positive examples than this", - ) - ap.add_argument( - "--method", - default="pooled", - choices=["pooled", "subspace"], - help="Concept-extraction method: 'pooled' (classic CAA, " - "pos_mean - neg_mean on last-token activations) or 'subspace' " - "(per-story SVD; top eigenvector of Σ V_i V_i^T for positives " - "minus same for baselines — captures what's common across " - "stories' full-trajectory subspaces)", - ) - ap.add_argument( - "--subspace-k", - type=int, - default=99999, - help="Max top-k right singular vectors per story for subspace method " - "(clamped to min(n_tokens, hidden_dim) per story). Default is " - "effectively 'keep full per-story subspace' — each story's V_i " - "spans its entire natural row space. On a hidden_dim=5120 " - "residual and ~500-token stories, that's ~500 vectors per story. " - "Memory is fine: 112 × 5120 × 500 × 4 bytes ≈ 1.1 GB.", - ) - ap.add_argument( - "--subspace-eigen-k", - type=int, - default=5, - help="Number of top eigenvectors of M_pos - M_base to combine into " - "the concept direction. Weighted sum by eigenvalue (so strongest " - "common directions contribute most). eigen_k=1 recovers " - "single-eigenvector behavior. Higher values (5-10) capture " - "richer structure when the concept's shared-subspace spectrum " - "is flat (which it tends to be in practice).", - ) - ap.add_argument( - "--quality-report", - action="store_true", - help="After training, compute a per-concept quality report " - "(SVD rank, per-story alignment, single-neuron alignment, " - "nearest-concept contamination) and write quality.json", - ) - args = ap.parse_args() - - target_layers = [int(x) for x in args.target_layers.split(",")] - dtype = { - "bf16": torch.bfloat16, - "fp16": torch.float16, - "fp32": torch.float32, - }[args.dtype] - - # Preflight: corpus dirs exist before we pay the cost of loading a 27B model - stories_dir = Path(args.stories_dir) - if not stories_dir.is_dir(): - raise FileNotFoundError( - f"--stories-dir {stories_dir!s} does not exist or is not a dir" - ) - if args.paired_dir is not None: - pd = Path(args.paired_dir) - if not pd.is_dir(): - raise FileNotFoundError( - f"--paired-dir {pd!s} does not exist or is not a dir" - ) - - # Quick corpus pre-scan so failures show up before we load the model. - positives_preview, baselines_preview = _load_corpus( - stories_dir, - Path(args.paired_dir) if args.paired_dir else None, - ) - n_emotions_preview = sum( - 1 for ps in positives_preview.values() - if len(ps) >= args.min_positives - ) - if n_emotions_preview == 0: - raise RuntimeError( - f"corpus has 0 emotions with >= {args.min_positives} positive " - f"examples. Check {stories_dir} — is it the right directory?" - ) - print( - f"Corpus preflight: {n_emotions_preview} emotions (min_positives=" - f"{args.min_positives}), {len(baselines_preview)} baselines" - ) - - print(f"Loading {args.model} ({args.dtype}) on {args.device}...") - tokenizer = AutoTokenizer.from_pretrained(args.model) - if tokenizer.pad_token_id is None: - tokenizer.pad_token = tokenizer.eos_token - model = AutoModelForCausalLM.from_pretrained( - args.model, - torch_dtype=dtype, - device_map=args.device, - low_cpu_mem_usage=True, - ) - # Multimodal configs (Qwen3.5-27B, etc.) nest the text-model - # dimensions under a text_config subobject. get_text_config() - # returns that sub-config when present, else the top-level config. - text_config = ( - model.config.get_text_config() - if hasattr(model.config, "get_text_config") - else model.config - ) - hidden_dim = text_config.hidden_size - n_model_layers = text_config.num_hidden_layers - print( - f"Model loaded. hidden_dim={hidden_dim}, " - f"n_model_layers={n_model_layers} " - f"(text_config.model_type={getattr(text_config, 'model_type', '?')})" - ) - - for layer_idx in target_layers: - if layer_idx < 0 or layer_idx >= n_model_layers: - raise ValueError( - f"target layer {layer_idx} out of range " - f"[0, {n_model_layers})" - ) - print( - "Target layers (relative depth): " - + ", ".join( - f"{l} ({100 * l / (n_model_layers - 1):.0f}%)" - for l in target_layers - ) - ) - - positives_by_emotion, baselines = _load_corpus( - Path(args.stories_dir), - Path(args.paired_dir) if args.paired_dir else None, - ) - emotions = sorted( - e for e, ps in positives_by_emotion.items() - if len(ps) >= args.min_positives - ) - if not emotions: - raise RuntimeError( - f"No emotions with >= {args.min_positives} positive examples" - ) - print( - f"Training {len(emotions)} emotions; " - f"{len(baselines)} baseline scenarios" - ) - - # Cache all positive-text activations once so we can reuse them as - # negatives for other emotions. Keyed by the text itself to dedup - # across emotion lists. - device = torch.device(args.device) - text_to_emotion: dict[str, str] = {} - for emotion, texts in positives_by_emotion.items(): - for t in texts: - text_to_emotion[t] = emotion - - unique_positive_texts = list(text_to_emotion.keys()) - print( - f"Collecting activations for {len(unique_positive_texts)} unique " - f"positive texts + {len(baselines)} baselines..." - ) - - positive_acts = _collect_activations( - model, tokenizer, unique_positive_texts, target_layers, device, - args.batch_size, args.max_length, label="positives", - ) - # positive_acts[i] corresponds to unique_positive_texts[i] - text_to_row = {t: i for i, t in enumerate(unique_positive_texts)} - - baseline_acts = ( - _collect_activations( - model, tokenizer, baselines, target_layers, device, - args.batch_size, args.max_length, label="baselines", - ) - if baselines - else torch.zeros(0, len(target_layers), hidden_dim) - ) - - n_concepts = len(emotions) - n_layers = len(target_layers) - - # Per-layer output matrices. Shape (n_concepts, hidden_size) each. - per_layer_vectors = torch.zeros( - (n_layers, n_concepts, hidden_dim), dtype=torch.float32 - ) - - # --- Subspace method: collect per-story right-singular-vector subspaces - # and use sum-of-projection-operators per concept. -------------------- - pos_subspaces: list[dict[int, torch.Tensor]] | None = None - base_subspaces: list[dict[int, torch.Tensor]] | None = None - # Per (concept, layer): top-20 eigenvalues of (M_pos - M_base), descending. - # Populated only when --method subspace. - subspace_eigvals: dict[str, dict[int, list[float]]] = {} - if args.method == "subspace": - print("\nCollecting per-story subspaces (SVD, top-k right singular " - f"vectors, k={args.subspace_k})...") - pos_subspaces = _collect_per_story_subspaces( - model, tokenizer, unique_positive_texts, target_layers, device, - args.batch_size, args.max_length, k=args.subspace_k, - label="subsp-pos", - ) - if baselines: - base_subspaces = _collect_per_story_subspaces( - model, tokenizer, baselines, target_layers, device, - args.batch_size, args.max_length, k=args.subspace_k, - label="subsp-base", - ) - else: - base_subspaces = [] - - for e_idx, emotion in enumerate(emotions): - pos_rows = [text_to_row[t] for t in positives_by_emotion[emotion]] - # Negatives: every OTHER emotion's positives + baselines. - neg_rows = [ - i - for i, t in enumerate(unique_positive_texts) - if text_to_emotion[t] != emotion - ] - - if args.method == "subspace": - # For each layer, build M_pos = Σ V V^T / n_pos, baseline same - # (using all other concepts' positive subspaces + baseline - # subspaces as the contrast set), top eigenvector of difference. - for l_idx, target_l in enumerate(target_layers): - pos_V = [pos_subspaces[j][target_l] for j in pos_rows] - base_V = [pos_subspaces[j][target_l] for j in neg_rows] - base_V += [bs[target_l] for bs in (base_subspaces or [])] - top_vec, eigvals = _subspace_concept_direction( - pos_V, base_V, hidden=hidden_dim, - top_k=args.subspace_eigen_k, - device=device, - ) - top_vec = top_vec.cpu() - eigvals = eigvals.cpu() - per_layer_vectors[l_idx, e_idx] = top_vec - # Keep the top-20 eigenvalues for quality-report diagnostics. - subspace_eigvals.setdefault(emotion, {})[target_l] = ( - eigvals[-20:].flip(0).tolist() - ) - else: - pos = positive_acts[pos_rows] # [n_pos, n_layers, hidden] - neg = positive_acts[neg_rows] # [n_neg, n_layers, hidden] - if baseline_acts.shape[0] > 0: - neg = torch.cat([neg, baseline_acts], dim=0) - - pos_mean = pos.mean(dim=0) # [n_layers, hidden] - neg_mean = neg.mean(dim=0) - diff = pos_mean - neg_mean - norms = diff.norm(dim=-1, keepdim=True).clamp_min(1e-6) - diff = diff / norms - - # diff[layer] -> per_layer_vectors[layer, e_idx] - for l_idx in range(n_layers): - per_layer_vectors[l_idx, e_idx] = diff[l_idx] - - if e_idx < 5 or e_idx == len(emotions) - 1: - print( - f" [{e_idx + 1}/{len(emotions)}] {emotion}: " - f"pos={len(pos_rows)} neg={len(neg_rows) + baseline_acts.shape[0]}" - f" (method={args.method})" - ) - - output_dir = Path(args.output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - tensors = { - f"layer_{target_layers[l_idx]}.vectors": ( - per_layer_vectors[l_idx].to(torch.float16) - ) - for l_idx in range(n_layers) - } - safetensors.torch.save_file( - tensors, - str(output_dir / "readout.safetensors"), - ) - manifest = { - "concepts": emotions, - "layers": target_layers, - "hidden_size": hidden_dim, - "dtype": "float16", - } - (output_dir / "readout.json").write_text( - json.dumps(manifest, indent=2) + "\n" - ) - - total_mb = sum(t.numel() * 2 for t in tensors.values()) / (1024 * 1024) - print( - f"\nWrote readout.safetensors + readout.json to {output_dir}\n" - f" {n_concepts} concepts x {n_layers} layers x " - f"{hidden_dim} dim (fp16), total {total_mb:.1f} MiB" - ) - - if args.quality_report: - print("\nComputing quality report...") - report = _compute_quality_report( - emotions=emotions, - positive_acts=positive_acts, - baseline_acts=baseline_acts, - positives_by_emotion=positives_by_emotion, - text_to_row=text_to_row, - per_layer_vectors=per_layer_vectors, - target_layers=target_layers, - model=model, - positive_texts=unique_positive_texts, - text_to_emotion=text_to_emotion, - ) - - # Per-head attention decomposition — second pass, captures - # o_proj's input at each target layer and ranks heads per concept - # by selectivity. Meta-relational concepts often live in specific - # attention heads rather than the residual-stream sum; this - # diagnostic surfaces that. - print("\nCollecting o_proj inputs for per-head analysis...") - attn_inputs, active_layers = _collect_attention_inputs( - model, tokenizer, unique_positive_texts, target_layers, device, - args.batch_size, args.max_length, label="attn-pos", - ) - if active_layers and baselines: - baseline_attn_inputs, _ = _collect_attention_inputs( - model, tokenizer, baselines, active_layers, device, - args.batch_size, args.max_length, label="attn-base", - ) - else: - baseline_attn_inputs = torch.zeros(0, len(active_layers), hidden_dim) - - if active_layers: - n_heads_per_layer = _get_n_heads_per_layer(model, active_layers) - per_head = _compute_per_head_ranking( - emotions=emotions, - attn_inputs=attn_inputs, - baseline_attn_inputs=baseline_attn_inputs, - positives_by_emotion=positives_by_emotion, - text_to_row=text_to_row, - active_layers=active_layers, - n_heads_per_layer=n_heads_per_layer, - text_to_emotion=text_to_emotion, - unique_positive_texts=unique_positive_texts, - ) - # Fold per-head into the main report under each concept. - for emotion, ph in per_head.items(): - if emotion in report: - report[emotion]["per_head"] = ph["per_layer"] - print(f"Per-head analysis done on layers {active_layers}") - else: - print( - "No layer exposed a recognisable o_proj module path — " - "per-head analysis skipped." - ) - - # Eigenvalue spectrum from the subspace method — if populated, report - # the top-20 eigenvalues per concept per layer. Tells us whether the - # concept direction lives in a single dominant dimension (λ_0 >> λ_1) - # or a spread of common directions (λ_0 ≈ λ_1 ≈ ...). - if subspace_eigvals: - for emotion, per_l in subspace_eigvals.items(): - if emotion in report: - report[emotion]["subspace_eigvals"] = { - str(l): vals for l, vals in per_l.items() - } - - # Linear combinations — for each concept, how much of its direction - # is explained by a ridge regression on the others. R² > 0.9 flags - # concepts that are essentially linear combinations of their peers - # (useful for teasing apart near-duplicate clusters). - print("\nComputing linear-combination analysis...") - lincomb = _compute_linear_combinations( - emotions, per_layer_vectors, target_layers - ) - for emotion, lc in lincomb.items(): - if emotion in report: - report[emotion]["linear_combination"] = lc["per_layer"] - - (output_dir / "quality.json").write_text( - json.dumps(report, indent=2) + "\n" - ) - - # Short summary: concepts in each triage bucket. - clean_single_neuron = [] - clean_circuit = [] - fragmented = [] - contaminated = [] - redundant = [] # R² > 0.9 — concept is near-linear combo of others - mid = n_layers // 2 - mid_layer = target_layers[mid] - for emotion in emotions: - per_l = report[emotion]["per_layer"][str(mid_layer)] - v = per_l["first_pc_variance_ratio"] - nb = per_l.get("best_neuron_cosine") or 0.0 - top_near = report[emotion]["nearest_concepts"] - nearest_cos = top_near[0][1] if top_near else 0.0 - lc_r2 = 0.0 - lc_entry = report[emotion].get("linear_combination", {}) - if str(mid_layer) in lc_entry: - lc_r2 = lc_entry[str(mid_layer)]["r_squared"] - if lc_r2 > 0.9: - redundant.append(emotion) - if nearest_cos > 0.8: - contaminated.append(emotion) - elif v > 0.7 and abs(nb) > 0.6: - clean_single_neuron.append(emotion) - elif v > 0.7: - clean_circuit.append(emotion) - elif v < 0.4: - fragmented.append(emotion) - print( - f"\nQuality summary @ layer {mid_layer}:\n" - f" clean (single-neuron): {len(clean_single_neuron)}\n" - f" clean (low-dim circuit): {len(clean_circuit)}\n" - f" fragmented (first-PC < 0.4): {len(fragmented)}\n" - f" contaminated (nearest > 0.8): {len(contaminated)}\n" - f" redundant (R² > 0.9 vs. others): {len(redundant)}" - ) - if fragmented: - print(f" fragmented sample: {fragmented[:5]}") - if contaminated: - print(f" contaminated sample: {contaminated[:5]}") - if redundant: - print(f" redundant sample: {redundant[:5]}") - print(f"\nWrote quality.json to {output_dir}") - - del model - gc.collect() - torch.cuda.empty_cache() - - -if __name__ == "__main__": - main() diff --git a/training/amygdala_training/train_with_library.py b/training/amygdala_training/train_with_library.py deleted file mode 100644 index c3997a1..0000000 --- a/training/amygdala_training/train_with_library.py +++ /dev/null @@ -1,407 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -"""Train concept-readout vectors using the steering-vectors library. - -Alternative to train_steering_vectors.py that uses the pip-installable -steering-vectors library (github.com/steering-vectors/steering-vectors) -instead of our hand-rolled diff-of-means + subspace machinery. The -library ships multiple aggregators out of the box: - - mean — pos_mean - neg_mean, unit-normed. Equivalent to our - default 'pooled' method. - pca — concatenates [pos-neg, neg-pos] and takes the top PC. - Implicit denoising: direction of maximum variance in the - paired deltas, less sensitive to per-pair noise than - plain mean. - logistic — trains a logistic-regression classifier on centered - activations; concept direction is the weight vector. - L1 penalty gives an explicit sparse vector (zeroes out - noise coords); L2 shrinks low-magnitude coords. - linear — same, with linear regression. - -Output is the same readout.safetensors + readout.json format the -trainer and vLLM plugin already understand. -""" - -from __future__ import annotations - -import argparse -import json -import random -from pathlib import Path - -import safetensors.torch -import torch -from transformers import AutoModelForCausalLM, AutoTokenizer - -from steering_vectors import ( - SteeringVectorTrainingSample, - train_steering_vector, -) -from steering_vectors.aggregators import ( - mean_aggregator, - pca_aggregator, - logistic_aggregator, -) - -# Reuse corpus loader from the hand-rolled trainer. -from training.amygdala_training.train_steering_vectors import _load_corpus - - -def _load_direct_descriptions( - direct_dir: Path, -) -> tuple[dict[str, list[str]], list[str]]: - """Load first-person phenomenological descriptions from ``direct_dir``. - - Each ``{concept}.txt`` holds 1+ descriptions separated by blank lines. - Files starting with ``_`` (e.g. ``_baseline.txt``) aren't concepts — - their descriptions go into every concept's negative pool. - - Returns: (positives_by_concept, extra_baselines) - """ - positives: dict[str, list[str]] = {} - baselines: list[str] = [] - for f in sorted(direct_dir.glob("*.txt")): - text = f.read_text() - descs = [d.strip() for d in text.split("\n\n") if d.strip()] - if f.stem.startswith("_"): - baselines.extend(descs) - else: - positives[f.stem] = descs - return positives, baselines - - -def _chat_template_wrap(tokenizer, text: str) -> str: - """Wrap raw text in a consistent chat template so positive/negative - activations are in the same regime. Using one generic user prompt for - both narrative stories and first-person direct descriptions: the prompt - cancels in the pos-neg delta, so what remains is the assistant content.""" - return tokenizer.apply_chat_template( - [ - {"role": "user", "content": "Say something."}, - {"role": "assistant", "content": text}, - ], - tokenize=False, - ) - - -def _samples_for_concept( - emotion: str, - positives_by_emotion: dict[str, list[str]], - baselines: list[str], - *, - max_negatives_per_positive: int = 3, - seed: int = 0, - wrap=None, -) -> list[SteeringVectorTrainingSample]: - """Build paired (pos, neg) training samples for one concept. - - For each positive story of ``emotion``, pair it with up to - ``max_negatives_per_positive`` randomly-sampled negatives drawn - from: (a) other emotions' positive stories, (b) scenario baselines. - - ``wrap``, if given, is applied to both positive_str and negative_str - (e.g. a chat-template wrapper). - - The library expects paired samples; we don't have true - counterfactual pairs for all concepts, so we approximate with - random cross-concept / baseline negatives. - """ - rng = random.Random(hash((emotion, seed)) & 0xFFFFFFFF) - neg_pool: list[str] = list(baselines) - for other, texts in positives_by_emotion.items(): - if other == emotion: - continue - neg_pool.extend(texts) - - w = wrap if wrap is not None else (lambda s: s) - - samples: list[SteeringVectorTrainingSample] = [] - for pos in positives_by_emotion[emotion]: - if not neg_pool: - continue - picks = rng.sample(neg_pool, min(max_negatives_per_positive, len(neg_pool))) - for neg in picks: - samples.append( - SteeringVectorTrainingSample( - positive_str=w(pos), - negative_str=w(neg), - ) - ) - return samples - - -def _fp32_wrap(inner): - """Wrap an aggregator so activations are cast to fp32 first. - - torch.svd / torch.linalg.svd don't support bf16 on either CUDA or CPU, - and Qwen3.5 runs in bf16. Cast before the aggregator sees the tensors. - """ - - def wrapped(pos_acts: torch.Tensor, neg_acts: torch.Tensor) -> torch.Tensor: - return inner(pos_acts.to(torch.float32), neg_acts.to(torch.float32)) - - return wrapped - - -def _pca_with_spectrum(spectrum_log: dict, concept_key: list[str]): - """PCA aggregator that also records the eigenvalue spectrum of the - pos-neg deltas under ``concept_key[0]`` in ``spectrum_log``. The key is - passed by reference (a 1-element list) so we can rebind it per concept - without recreating the aggregator closure.""" - - @torch.no_grad() - def agg(pos_acts: torch.Tensor, neg_acts: torch.Tensor) -> torch.Tensor: - pos = pos_acts.to(torch.float32) - neg = neg_acts.to(torch.float32) - deltas = pos - neg - # Uncentered PCA: concatenate deltas and -deltas (library convention). - X = torch.cat([deltas, -deltas]) - # Eigenvalues via SVD: sigma^2 are the variances along each PC. - # torch.linalg.svd returns U, S, Vh where columns of Vh.T are PCs. - _, s, vh = torch.linalg.svd(X, full_matrices=False) - variances = (s ** 2) - total = variances.sum().item() - var_list = variances.tolist() - first_pc_ratio = var_list[0] / total if total > 0 else 0.0 - - # Participation ratio over the FULL spectrum — includes noise tail. - eff_dim_full = (total ** 2) / float((variances ** 2).sum().item() or 1.0) - - # Signal/noise split: find smallest k with cumulative variance ≥ 0.9, - # then compute PR over just those top-k eigenvalues. If PCA denoising - # is clean, eff_dim_signal should ≈ k_signal (the retained dims carry - # roughly equal variance, with the noise tail dropped). - cum = 0.0 - k_signal = len(var_list) - for i, v in enumerate(var_list): - cum += v - if cum / total >= 0.9: - k_signal = i + 1 - break - top_vars = variances[:k_signal] - top_total = top_vars.sum().item() - eff_dim_signal = (top_total ** 2) / float((top_vars ** 2).sum().item() or 1.0) - - spectrum_log[concept_key[0]] = { - "first_pc_ratio": round(first_pc_ratio, 4), - "effective_dim_full": round(eff_dim_full, 3), - "k_signal_at_90pct": k_signal, - "effective_dim_signal": round(eff_dim_signal, 3), - "top10_eigenvalues": [round(v, 4) for v in var_list[:10]], - "total_variance": round(total, 4), - } - # Top-1 PC - vec = vh[0] - # Sign-flip so the direction aligns with most deltas (library convention). - sign = torch.sign(torch.mean(deltas @ vec)) - return sign * vec - - return agg - - -def _aggregator_from_name(name: str): - if name == "mean": - return _fp32_wrap(mean_aggregator()) - if name == "pca": - return _fp32_wrap(pca_aggregator()) - if name == "logistic": - return _fp32_wrap(logistic_aggregator()) - if name == "logistic_l1": - return _fp32_wrap( - logistic_aggregator( - sklearn_kwargs={"penalty": "l1", "solver": "liblinear", "C": 0.1} - ) - ) - raise ValueError(f"unknown aggregator: {name}") - - -def main() -> None: - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--model", required=True) - ap.add_argument("--stories-dir", required=True) - ap.add_argument("--paired-dir", default=None) - ap.add_argument("--direct-dir", default=None, - help="Optional: directory of {concept}.txt files with 1+ " - "first-person descriptions separated by blank lines. " - "Files starting with _ contribute to every concept's " - "negative pool rather than being concepts themselves.") - ap.add_argument("--chat-template", action="store_true", - help="Wrap all text in assistant-role chat template. " - "Recommended when --direct-dir is used.") - ap.add_argument("--target-layers", required=True, help="Comma-separated layer indices") - ap.add_argument("--output-dir", required=True) - ap.add_argument("--dtype", default="bf16", choices=["bf16", "fp16", "fp32"]) - ap.add_argument("--batch-size", type=int, default=2) - ap.add_argument("--max-length", type=int, default=512) - ap.add_argument("--device", default="cuda:0") - ap.add_argument("--min-positives", type=int, default=1) - ap.add_argument( - "--aggregator", - default="mean", - choices=["mean", "pca", "logistic", "logistic_l1"], - ) - ap.add_argument("--max-negatives-per-positive", type=int, default=3) - args = ap.parse_args() - - target_layers = [int(x) for x in args.target_layers.split(",")] - dtype = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32}[ - args.dtype - ] - - stories_dir = Path(args.stories_dir) - paired_dir = Path(args.paired_dir) if args.paired_dir else None - positives_by_emotion, baselines = _load_corpus(stories_dir, paired_dir) - - if args.direct_dir: - direct_pos, direct_baselines = _load_direct_descriptions(Path(args.direct_dir)) - for concept, descs in direct_pos.items(): - positives_by_emotion.setdefault(concept, []).extend(descs) - baselines.extend(direct_baselines) - print( - f"Loaded {len(direct_pos)} direct-description concepts " - f"+ {len(direct_baselines)} baselines from {args.direct_dir}" - ) - - emotions = sorted( - e for e, ps in positives_by_emotion.items() if len(ps) >= args.min_positives - ) - if not emotions: - raise RuntimeError( - f"no emotions with >= {args.min_positives} positives in {stories_dir}" - ) - - print( - f"Training {len(emotions)} concepts via steering-vectors " - f"aggregator={args.aggregator!r} on layers={target_layers}" - ) - - print(f"Loading {args.model} ({args.dtype}) on {args.device}...") - tokenizer = AutoTokenizer.from_pretrained(args.model) - if tokenizer.pad_token_id is None: - tokenizer.pad_token = tokenizer.eos_token - model = AutoModelForCausalLM.from_pretrained( - args.model, torch_dtype=dtype, device_map=args.device, low_cpu_mem_usage=True - ) - model.eval() - - text_config = ( - model.config.get_text_config() - if hasattr(model.config, "get_text_config") - else model.config - ) - hidden_dim = getattr(text_config, "hidden_size", None) or getattr( - text_config, "hidden_dim", None - ) - assert hidden_dim, "couldn't infer hidden_dim from model config" - - # Per-layer output: [n_concepts, hidden] - per_layer_vectors = torch.zeros( - (len(target_layers), len(emotions), hidden_dim), dtype=torch.float32 - ) - - # Optional spectrum-logging aggregator (only for --aggregator pca). - spectrum_log: dict = {} - concept_key = [""] - if args.aggregator == "pca": - aggregator = _pca_with_spectrum(spectrum_log, concept_key) - else: - aggregator = _aggregator_from_name(args.aggregator) - - wrap = (lambda s: _chat_template_wrap(tokenizer, s)) if args.chat_template else None - if args.chat_template: - sample_text = wrap(positives_by_emotion[emotions[0]][0]) - print(f"\nSample templated input:\n{sample_text[:400]!r}\n") - - for e_idx, emotion in enumerate(emotions): - samples = _samples_for_concept( - emotion, - positives_by_emotion, - baselines, - max_negatives_per_positive=args.max_negatives_per_positive, - wrap=wrap, - ) - if not samples: - print(f" [{e_idx + 1}/{len(emotions)}] {emotion}: NO SAMPLES, skipping") - continue - - concept_key[0] = emotion # tell the aggregator which concept is being trained - - sv = train_steering_vector( - model, - tokenizer, - samples, - layers=target_layers, - aggregator=aggregator, - batch_size=args.batch_size, - show_progress=False, - move_to_cpu=True, - ) - # sv.layer_activations is a dict {layer_idx: tensor[hidden]} - for l_idx, layer in enumerate(target_layers): - vec = sv.layer_activations.get(layer) - if vec is None: - print(f" WARN: no vector returned for layer {layer} on {emotion}") - continue - vec = vec.detach().to(torch.float32).cpu() - vec = vec / vec.norm().clamp_min(1e-6) - per_layer_vectors[l_idx, e_idx] = vec - - if e_idx < 5 or e_idx == len(emotions) - 1 or e_idx % 10 == 0: - print( - f" [{e_idx + 1}/{len(emotions)}] {emotion}: " - f"n_samples={len(samples)} layers={target_layers}" - ) - - output_dir = Path(args.output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - tensors = { - f"layer_{target_layers[l_idx]}.vectors": per_layer_vectors[l_idx].to( - torch.float16 - ) - for l_idx in range(len(target_layers)) - } - safetensors.torch.save_file(tensors, str(output_dir / "readout.safetensors")) - (output_dir / "readout.json").write_text( - json.dumps( - { - "concepts": emotions, - "layers": target_layers, - "hidden_size": hidden_dim, - "dtype": "float16", - "aggregator": args.aggregator, - }, - indent=2, - ) - + "\n" - ) - if spectrum_log: - (output_dir / "spectrum.json").write_text(json.dumps(spectrum_log, indent=2) + "\n") - print("\n=== eigenvalue spectrum per concept ===") - print( - " concept first_pc k_90pct " - "eff_dim_signal eff_dim_full (signal/k ratio)" - ) - items = sorted(spectrum_log.items(), key=lambda kv: -kv[1]["first_pc_ratio"]) - for concept, stats in items: - k = stats["k_signal_at_90pct"] - eff_sig = stats["effective_dim_signal"] - ratio = eff_sig / k if k else 0.0 - print( - f" {concept:22s} " - f"{stats['first_pc_ratio']:>8.3f} " - f"{k:>7d} " - f"{eff_sig:>14.2f} " - f"{stats['effective_dim_full']:>12.2f} " - f"({ratio:.2f})" - ) - - total_mb = sum(t.numel() * 2 for t in tensors.values()) / (1024 * 1024) - print( - f"\nWrote readout.safetensors + readout.json to {output_dir} " - f"({len(emotions)} concepts x {len(target_layers)} layers, {total_mb:.1f} MiB)" - ) - - -if __name__ == "__main__": - main() diff --git a/training/apollo_plugin/optimizer.py b/training/apollo_mini.py similarity index 97% rename from training/apollo_plugin/optimizer.py rename to training/apollo_mini.py index 9abce94..166ae3a 100644 --- a/training/apollo_plugin/optimizer.py +++ b/training/apollo_mini.py @@ -8,9 +8,9 @@ Channel-wise or tensor-wise scaling is sufficient. Apollo approximates these scaling factors using a low-rank auxiliary optimizer state based on pure random projection. -Default rank=64. ~2.5GB state for 27B model, <0.25% compute overhead -vs forward+backward. Sufficient for behavioral training with diverse -examples. +Default rank=256 (full Apollo). ~10GB state for 27B model, <0.25% +compute overhead vs forward+backward. Captures gradient structure +across 100+ behavioral training examples per batch. Key implementation details from the paper: - Gradient scale factor α = √(n/r) compensates for projection ratio @@ -34,7 +34,7 @@ class Apollo(Optimizer): Args: params: model parameters lr: learning rate (default: 1e-4) - rank: projection rank (default: 64) + rank: projection rank (default: 256) betas: Adam momentum coefficients (default: (0.9, 0.999)) eps: numerical stability term (default: 1e-8) weight_decay: decoupled weight decay (default: 0.01) @@ -46,7 +46,7 @@ class Apollo(Optimizer): Set to None to disable. """ - def __init__(self, params, lr=1e-4, rank=64, betas=(0.9, 0.999), + def __init__(self, params, lr=1e-4, rank=256, betas=(0.9, 0.999), eps=1e-8, weight_decay=0.01, warmup_steps=0, scale=None, proj_refresh=200, norm_growth_limit=1.01): defaults = dict(lr=lr, rank=rank, betas=betas, eps=eps, diff --git a/training/apollo_plugin/__init__.py b/training/apollo_plugin/__init__.py deleted file mode 100644 index b2e121e..0000000 --- a/training/apollo_plugin/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Apollo training plugin for vLLM. - -Enables continuous fine-tuning alongside live inference by: -1. Exporting CUDA IPC handles for weight sharing (export_hook) -2. Adding /train endpoint to vLLM's HTTP server (train_router) -3. Block-level checkpoint sync to safetensors files - -Install: pip install -e /path/to/training -Then vLLM auto-loads via entry point. -""" - -from .export_hook import _patch_model_runner -from .train_router import _patch_api_server - - -def register(): - """Called by vLLM's plugin loader on startup.""" - _patch_model_runner() - _patch_api_server() diff --git a/training/apollo_plugin/checkpoint_sync.py b/training/apollo_plugin/checkpoint_sync.py deleted file mode 100644 index c2d7b2f..0000000 --- a/training/apollo_plugin/checkpoint_sync.py +++ /dev/null @@ -1,503 +0,0 @@ -"""Sync live GPU weights to safetensors files on disk. - -Reads vLLM weight tensors via CUDA IPC handles, converts from vLLM's -merged layout to HuggingFace's separate layout, diffs block-by-block -against on-disk safetensors files, and writes only changed blocks. - -For small behavioral training steps, this turns a 54GB checkpoint -write into a few hundred MB of actual disk I/O. - -Usage: - # Sync live weights to disk - python checkpoint_sync.py sync --model-dir /path/to/Qwen3.5-27B - - # Debug name mapping issues - python checkpoint_sync.py diagnose --model-dir /path/to/Qwen3.5-27B - - # From Python: - from checkpoint_sync import checkpoint_sync - result = checkpoint_sync("/path/to/model") -""" - -import json -import mmap -import struct -import sys -from pathlib import Path -from typing import Dict, List, Tuple, Any -import logging - -import torch - -logger = logging.getLogger(__name__) - -DEFAULT_BLOCK_SIZE = 4096 # 4KB blocks — matches filesystem block size -DEFAULT_HANDLES_PATH = "/tmp/vllm_weight_handles.pt" - - -# --------------------------------------------------------------------------- -# vLLM → HuggingFace weight name/shape conversion -# --------------------------------------------------------------------------- -# Qwen3.5-27B dimensions (could be read from config.json for generality) - -HIDDEN = 5120 -NUM_K_HEADS = 16 -NUM_V_HEADS = 48 -HEAD_K_DIM = 128 -HEAD_V_DIM = 128 -KEY_DIM = NUM_K_HEADS * HEAD_K_DIM # 2048 -VALUE_DIM = NUM_V_HEADS * HEAD_V_DIM # 6144 -INTERMEDIATE = 17408 - -# Full attention (some layers use standard attention, not GDN) -NUM_ATTN_HEADS = 24 -NUM_ATTN_KV_HEADS = 4 -ATTN_HEAD_DIM = 256 -ATTN_Q_HEAD_DIM = ATTN_HEAD_DIM * 2 # 512 -ATTN_Q_DIM = NUM_ATTN_HEADS * ATTN_Q_HEAD_DIM # 12288 -ATTN_K_DIM = NUM_ATTN_KV_HEADS * ATTN_HEAD_DIM # 1024 -ATTN_V_DIM = NUM_ATTN_KV_HEADS * ATTN_HEAD_DIM # 1024 - - -def vllm_to_hf_tensors(vllm_params: Dict[str, torch.Tensor] - ) -> Dict[str, torch.Tensor]: - """Convert vLLM merged weights to HF-compatible separate tensors. - - vLLM merges certain projections for efficiency: - - qkv_proj (full attn) → q_proj, k_proj, v_proj - - in_proj_qkvz (GDN) → in_proj_qkv, in_proj_z - - in_proj_ba (GDN) → in_proj_b, in_proj_a - - gate_up_proj (MLP) → gate_proj, up_proj - - Returns views that share GPU memory with the original tensors. - """ - hf_params = {} - - for name, tensor in vllm_params.items(): - # Strip vLLM's 'language_model.' prefix to match HF naming - hf_name = name.removeprefix('language_model.') - - if 'in_proj_qkvz' in name: - # GDN layer: [key*2 + value*2, hidden] → qkv + z - prefix = hf_name.replace('in_proj_qkvz.weight', '') - split_at = KEY_DIM * 2 + VALUE_DIM - hf_params[prefix + 'in_proj_qkv.weight'] = tensor[:split_at] - hf_params[prefix + 'in_proj_z.weight'] = tensor[split_at:] - - elif 'in_proj_ba' in name: - # GDN layer: [num_v_heads*2, hidden] → b + a - prefix = hf_name.replace('in_proj_ba.weight', '') - hf_params[prefix + 'in_proj_b.weight'] = tensor[:NUM_V_HEADS] - hf_params[prefix + 'in_proj_a.weight'] = tensor[NUM_V_HEADS:] - - elif 'qkv_proj' in name: - # Full attention: [q + k + v, hidden] → separate - prefix = hf_name.replace('qkv_proj.weight', '') - hf_params[prefix + 'q_proj.weight'] = tensor[:ATTN_Q_DIM] - hf_params[prefix + 'k_proj.weight'] = tensor[ATTN_Q_DIM:ATTN_Q_DIM + ATTN_K_DIM] - hf_params[prefix + 'v_proj.weight'] = tensor[ATTN_Q_DIM + ATTN_K_DIM:] - - elif 'gate_up_proj' in name: - # MLP: [intermediate*2, hidden] → gate + up - prefix = hf_name.replace('gate_up_proj.weight', '') - hf_params[prefix + 'gate_proj.weight'] = tensor[:INTERMEDIATE] - hf_params[prefix + 'up_proj.weight'] = tensor[INTERMEDIATE:] - - else: - # Pass through unchanged - hf_params[hf_name] = tensor - - return hf_params - - -# --------------------------------------------------------------------------- -# Safetensors file handling -# --------------------------------------------------------------------------- - -def read_safetensors_index(model_dir: Path) -> Dict[str, str]: - """Map tensor names to safetensors filenames. - - For sharded models, reads model.safetensors.index.json. - For single-file models, returns empty dict (default to model.safetensors). - """ - index_path = model_dir / "model.safetensors.index.json" - if not index_path.exists(): - return {} - - with open(index_path) as f: - index = json.load(f) - - return dict(index.get("weight_map", {})) - - -def parse_safetensors_header(data: memoryview) -> Tuple[int, dict]: - """Parse safetensors file header. - - Returns (data_start_offset, header_dict). - Header dict maps tensor names to metadata including 'data_offsets'. - """ - header_size = struct.unpack(' Tuple[int, int]: - """Sync a single tensor to mmap'd file using block-level diffing. - - Returns (bytes_compared, bytes_changed). - """ - start = data_start + offsets[0] - end = data_start + offsets[1] - disk_len = end - start - - # Transfer tensor to CPU and get raw bytes - # Use .detach() to avoid autograd overhead, .contiguous() for memory layout - try: - live_bytes = tensor.detach().contiguous().cpu().numpy().tobytes() - except Exception as e: - logger.warning(f"Failed to transfer {name} to CPU: {e}") - return 0, 0 - - if len(live_bytes) != disk_len: - logger.warning( - f"Size mismatch for {name}: disk={disk_len}, live={len(live_bytes)} " - f"(shape={list(tensor.shape)}, dtype={tensor.dtype})" - ) - return 0, 0 - - # Block-level diff: compare and write only changed blocks - compared = 0 - changed = 0 - offset = 0 - - while offset < disk_len: - block_end = min(offset + block_size, disk_len) - block_len = block_end - offset - - disk_block = mm[start + offset:start + block_end] - live_block = live_bytes[offset:block_end] - - compared += block_len - - if disk_block != live_block: - mm[start + offset:start + block_end] = live_block - changed += block_len - - offset = block_end - - return compared, changed - - -def sync_file( - file_path: Path, - tensors: Dict[str, torch.Tensor], - block_size: int, -) -> Tuple[int, int, int, int]: - """Sync tensors to a single safetensors file. - - Returns (bytes_compared, bytes_changed, tensors_found, tensors_missing). - """ - with open(file_path, 'r+b') as f: - mm = mmap.mmap(f.fileno(), 0) - - try: - data_start, header = parse_safetensors_header(memoryview(mm)) - - total_compared = 0 - total_changed = 0 - found = 0 - missing = 0 - - for name, tensor in tensors.items(): - if name == "__metadata__": - continue - - if name not in header: - missing += 1 - continue - - found += 1 - meta = header[name] - offsets = meta['data_offsets'] - - compared, changed = sync_tensor_to_mmap( - mm, name, tensor, data_start, offsets, block_size - ) - total_compared += compared - total_changed += changed - - # Flush changes to disk - if total_changed > 0: - mm.flush() - - return total_compared, total_changed, found, missing - - finally: - mm.close() - - -# --------------------------------------------------------------------------- -# Main entry point -# --------------------------------------------------------------------------- - -def load_vllm_weights(handles_path: str) -> Dict[str, torch.Tensor]: - """Load vLLM weight tensors from CUDA IPC handles. - - The handles file is written by vllm_export_hook.py on vLLM startup. - Each handle can be used to reconstruct a tensor pointing to vLLM's - GPU memory — no copy, direct access. - """ - handles = torch.load(handles_path, weights_only=False) - - # Skip metadata entry - handles.pop('__metadata__', None) - - weights = {} - for name, info in handles.items(): - func, args = info['handle'] - try: - weights[name] = func(*args) - except Exception as e: - logger.warning(f"Failed to reconstruct {name}: {e}") - - return weights - - -def checkpoint_sync( - model_dir: str, - handles_path: str = DEFAULT_HANDLES_PATH, - block_size: int = DEFAULT_BLOCK_SIZE, -) -> Dict[str, Any]: - """Sync live GPU weights to model safetensors files. - - This is the main entry point. Call this after training steps - or periodically to checkpoint weights without full serialization. - - Args: - model_dir: Directory containing safetensors files - handles_path: Path to vLLM weight IPC handles file - block_size: Block size for diffing (default 4KB) - - Returns: - Dict with sync statistics: - - total_compared: bytes compared - - total_changed: bytes actually written - - files_changed: list of modified filenames - - tensors_synced: number of tensors processed - - tensors_missing: tensors not found in safetensors - """ - model_dir = Path(model_dir) - - if not Path(handles_path).exists(): - raise FileNotFoundError( - f"Weight handles not found: {handles_path}. " - "Is vLLM running with the export hook?" - ) - - # Step 1: Load live weights from GPU via IPC - logger.info("Loading live weights from GPU...") - vllm_weights = load_vllm_weights(handles_path) - logger.info(f" Loaded {len(vllm_weights)} vLLM tensors") - - # Step 2: Convert to HF naming/layout - hf_weights = vllm_to_hf_tensors(vllm_weights) - logger.info(f" Converted to {len(hf_weights)} HF tensors") - - # Step 3: Map tensors to safetensors files - weight_map = read_safetensors_index(model_dir) - - by_file: Dict[str, Dict[str, torch.Tensor]] = {} - unmapped = [] - - for name, tensor in hf_weights.items(): - filename = weight_map.get(name) - if filename is None: - # Single-file model or missing from index - if (model_dir / "model.safetensors").exists(): - filename = "model.safetensors" - else: - unmapped.append(name) - continue - by_file.setdefault(filename, {})[name] = tensor - - if unmapped: - logger.warning(f" {len(unmapped)} tensors not in index: {unmapped[:3]}...") - - # Step 4: Sync each file - total_compared = 0 - total_changed = 0 - total_found = 0 - total_missing = 0 - files_changed = [] - - for filename in sorted(by_file.keys()): - tensors = by_file[filename] - file_path = model_dir / filename - - if not file_path.exists(): - logger.warning(f" File not found: {filename}") - total_missing += len(tensors) - continue - - compared, changed, found, missing = sync_file(file_path, tensors, block_size) - - total_compared += compared - total_changed += changed - total_found += found - total_missing += missing - - if changed > 0: - files_changed.append(filename) - logger.info(f" {filename}: {changed / 1e6:.2f} MB changed ({found} tensors)") - - # Summary - if total_changed == 0: - logger.info("No changes - model files are up to date") - else: - pct = (total_changed / total_compared * 100) if total_compared > 0 else 0 - logger.info( - f"Synced: {total_changed / 1e6:.2f} MB changed / " - f"{total_compared / 1e9:.2f} GB compared ({pct:.3f}%)" - ) - - if total_missing > 0: - logger.warning(f" {total_missing} tensors not found in safetensors files") - - return { - "total_compared": total_compared, - "total_changed": total_changed, - "files_changed": files_changed, - "tensors_synced": total_found, - "tensors_missing": total_missing, - } - - -# --------------------------------------------------------------------------- -# Diagnostics -# --------------------------------------------------------------------------- - -def diagnose(model_dir: str, handles_path: str = DEFAULT_HANDLES_PATH): - """Print diagnostic info about weight name mappings. - - Useful for debugging mismatches between vLLM and safetensors names. - """ - model_dir = Path(model_dir) - - # Load and convert vLLM weights - vllm_weights = load_vllm_weights(handles_path) - hf_weights = vllm_to_hf_tensors(vllm_weights) - hf_names = set(hf_weights.keys()) - - # Read safetensors index - weight_map = read_safetensors_index(model_dir) - disk_names = set(weight_map.keys()) - - # If single-file model, parse that file's header - if not disk_names: - st_path = model_dir / "model.safetensors" - if st_path.exists(): - with open(st_path, 'rb') as f: - mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) - _, header = parse_safetensors_header(memoryview(mm)) - disk_names = {k for k in header.keys() if k != "__metadata__"} - mm.close() - - print(f"vLLM tensors (raw): {len(vllm_weights)}") - print(f"HF tensors (converted): {len(hf_names)}") - print(f"Disk tensors: {len(disk_names)}") - print() - - in_both = hf_names & disk_names - only_hf = hf_names - disk_names - only_disk = disk_names - hf_names - - print(f"Matched: {len(in_both)}") - print(f"Only in HF (won't sync): {len(only_hf)}") - print(f"Only on disk (not updated): {len(only_disk)}") - - if only_hf: - print(f"\nSample HF-only: {sorted(only_hf)[:5]}") - if only_disk: - print(f"\nSample disk-only: {sorted(only_disk)[:5]}") - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - -def main(): - import argparse - - parser = argparse.ArgumentParser( - description="Sync live GPU weights to safetensors files" - ) - subparsers = parser.add_subparsers(dest="command", help="Command") - - # sync command - sync_parser = subparsers.add_parser("sync", help="Sync weights to disk") - sync_parser.add_argument( - "--model-dir", required=True, - help="Directory containing safetensors files" - ) - sync_parser.add_argument( - "--handles", default=DEFAULT_HANDLES_PATH, - help=f"Path to IPC handles (default: {DEFAULT_HANDLES_PATH})" - ) - sync_parser.add_argument( - "--block-size", type=int, default=DEFAULT_BLOCK_SIZE, - help=f"Block size for diffing (default: {DEFAULT_BLOCK_SIZE})" - ) - sync_parser.add_argument( - "-v", "--verbose", action="store_true", - help="Verbose output" - ) - - # diagnose command - diag_parser = subparsers.add_parser("diagnose", help="Check name mappings") - diag_parser.add_argument( - "--model-dir", required=True, - help="Directory containing safetensors files" - ) - diag_parser.add_argument( - "--handles", default=DEFAULT_HANDLES_PATH, - help=f"Path to IPC handles (default: {DEFAULT_HANDLES_PATH})" - ) - - args = parser.parse_args() - - if args.command is None: - parser.print_help() - sys.exit(1) - - logging.basicConfig( - level=logging.DEBUG if getattr(args, 'verbose', False) else logging.INFO, - format='%(message)s' - ) - - try: - if args.command == "sync": - result = checkpoint_sync(args.model_dir, args.handles, args.block_size) - print(json.dumps(result, indent=2)) - elif args.command == "diagnose": - diagnose(args.model_dir, args.handles) - except FileNotFoundError as e: - logger.error(str(e)) - sys.exit(1) - except Exception as e: - logger.exception(f"Failed: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/training/apollo_plugin/train_router.py b/training/apollo_plugin/train_router.py deleted file mode 100644 index d6f90b4..0000000 --- a/training/apollo_plugin/train_router.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Training endpoint for vLLM - forwards to training subprocess via ZMQ. - -Patches vLLM's build_app() to add /train route. The actual training runs -in a dedicated subprocess (training_worker.py) to avoid blocking the -event loop and to keep training work isolated from vLLM internals. -""" - -import asyncio -import logging -import os -import subprocess -import sys -from datetime import datetime -from pathlib import Path -from typing import Any - -import zmq -import zmq.asyncio - -from fastapi import APIRouter, FastAPI -from fastapi.responses import JSONResponse -from pydantic import BaseModel - -logger = logging.getLogger(__name__) - -router = APIRouter() - -DEFAULT_ZMQ_ADDR = "ipc:///tmp/apollo_training.sock" - -# Global state for subprocess management -_worker_process: subprocess.Popen | None = None -_zmq_context: zmq.asyncio.Context | None = None -_zmq_socket: zmq.asyncio.Socket | None = None -_initialized: bool = False - - -class TrainRequest(BaseModel): - training_data: dict[str, Any] # {"samples": [...], "config": {...}} - - -class TrainResponse(BaseModel): - job_id: str - status: str - training_samples: int - loss_history: list[float] - - -def _start_worker_subprocess(): - """Start the training worker subprocess.""" - global _worker_process - - if _worker_process is not None and _worker_process.poll() is None: - return # Still running - - # Start worker as subprocess using script path - worker_script = Path(__file__).parent / 'training_worker.py' - _worker_process = subprocess.Popen( - [sys.executable, str(worker_script)], - env={**os.environ, 'APOLLO_ZMQ_ADDR': DEFAULT_ZMQ_ADDR}, - ) - logger.info(f"Started training worker subprocess (pid={_worker_process.pid})") - - # Give it a moment to bind the socket - import time - time.sleep(0.5) - - -def _ensure_initialized(): - """Ensure subprocess is running and ZMQ socket is connected.""" - global _zmq_context, _zmq_socket, _initialized - - if _initialized: - return - - # Start worker if needed - _start_worker_subprocess() - - # Create async ZMQ context and socket - _zmq_context = zmq.asyncio.Context() - _zmq_socket = _zmq_context.socket(zmq.REQ) - _zmq_socket.connect(DEFAULT_ZMQ_ADDR) - - # Set timeout for recv - _zmq_socket.setsockopt(zmq.RCVTIMEO, 300000) # 5 minute timeout for training - - _initialized = True - logger.info(f"Connected to training worker at {DEFAULT_ZMQ_ADDR}") - - -async def _send_request(request: dict[str, Any]) -> dict[str, Any]: - """Send request to worker and wait for response.""" - _ensure_initialized() - - # ZMQ async send/recv - await _zmq_socket.send_json(request) - response = await _zmq_socket.recv_json() - return response - - -@router.post("/train") -async def handle_train(request: TrainRequest): - """Handle training request - forwards to training subprocess.""" - try: - _ensure_initialized() - except Exception as e: - return JSONResponse( - content={"error": f"Training not available: {e}"}, - status_code=503, - ) - - try: - training_data = request.training_data - samples = training_data.get("samples", []) - config = training_data.get("config", {}) - - if not samples: - return JSONResponse( - content={"error": "No training samples provided"}, - status_code=400, - ) - - job_id = f"job_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - logger.info(f"Starting training job {job_id} with {len(samples)} samples") - - # Forward to worker - response = await _send_request({ - 'type': 'train', - 'samples': samples, - 'config': config, - }) - - if 'error' in response: - return JSONResponse( - content={"error": response['error']}, - status_code=500, - ) - - logger.info( - f"Training job {job_id} completed, " - f"final loss: {response['loss_history'][-1]:.4f}" - ) - - return JSONResponse(content={ - "job_id": job_id, - "status": response['status'], - "training_samples": response['training_samples'], - "loss_history": response['loss_history'], - }) - - except zmq.Again: - logger.error("Training request timed out") - return JSONResponse( - content={"error": "Training request timed out"}, - status_code=504, - ) - except Exception as e: - logger.exception(f"Training failed: {e}") - return JSONResponse( - content={"error": str(e)}, - status_code=500, - ) - - -@router.post("/checkpoint") -async def handle_checkpoint(): - """Trigger checkpoint sync to disk.""" - try: - _ensure_initialized() - except Exception as e: - return JSONResponse( - content={"error": f"Training not available: {e}"}, - status_code=503, - ) - - try: - response = await _send_request({'type': 'checkpoint'}) - - if 'error' in response: - return JSONResponse( - content={"error": response['error']}, - status_code=500, - ) - - return JSONResponse(content=response) - - except Exception as e: - logger.exception(f"Checkpoint failed: {e}") - return JSONResponse( - content={"error": str(e)}, - status_code=500, - ) - - -@router.get("/train/status") -async def handle_status(): - """Get training worker status.""" - try: - _ensure_initialized() - except Exception as e: - return JSONResponse( - content={ - "status": "unavailable", - "error": str(e), - }, - status_code=503, - ) - - try: - response = await _send_request({'type': 'status'}) - return JSONResponse(content=response) - - except Exception as e: - return JSONResponse( - content={ - "status": "error", - "error": str(e), - }, - status_code=500, - ) - - -def attach_router(app: FastAPI): - """Attach training router to FastAPI app.""" - app.include_router(router) - logger.info("Training router attached") - - -def _patch_api_server(): - """Patch vLLM's build_app to include our training router.""" - from vllm.entrypoints.openai import api_server - - original_build_app = api_server.build_app - - def patched_build_app(*args, **kwargs): - app = original_build_app(*args, **kwargs) - attach_router(app) - return app - - api_server.build_app = patched_build_app - logger.info("API server patched for /train endpoint") diff --git a/training/apollo_plugin/training_worker.py b/training/apollo_plugin/training_worker.py deleted file mode 100644 index f8b8c23..0000000 --- a/training/apollo_plugin/training_worker.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Training subprocess - handles Apollo training and checkpoint sync. - -Long-lived process that: -1. Loads IPC handles from vLLM's exported weights -2. Creates HF model with views into vLLM's GPU memory -3. Handles training requests via ZMQ -4. Handles checkpoint sync requests -5. Persists Apollo optimizer state between calls - -Communicates with the API server's /train endpoint via ZMQ REP socket. -""" - -import logging -import os -import signal -import sys -from pathlib import Path -from typing import Any - -# Handle running as script vs module -if __name__ == '__main__' and __package__ is None: - # Running as script - add parent to path for imports - sys.path.insert(0, str(Path(__file__).parent.parent)) - __package__ = 'apollo_plugin' - -import torch -import torch.nn as nn -import zmq - -from .checkpoint_sync import checkpoint_sync -from .optimizer import Apollo -from .weight_mapping import load_hf_model_with_vllm_weights - -logger = logging.getLogger(__name__) - -DEFAULT_RANK = 64 -DEFAULT_ZMQ_ADDR = "ipc:///tmp/apollo_training.sock" -HANDLE_PATH = "/tmp/vllm_weight_handles.pt" -OPTIMIZER_STATE_PATH = "/tmp/apollo_optimizer_state.pt" - - -class TrainingWorker: - """Long-lived training worker process.""" - - def __init__(self, zmq_addr: str = DEFAULT_ZMQ_ADDR): - self.zmq_addr = zmq_addr - self.model: nn.Module | None = None - self.optimizer: Apollo | None = None - self.model_path: str | None = None - self._running = True - - def _create_model_wrapper(self) -> nn.Module: - """Create HF model wrapper with views into vLLM's GPU memory.""" - if not os.path.exists(HANDLE_PATH): - raise FileNotFoundError( - f"Weight handles not found: {HANDLE_PATH}. " - "Is vLLM running with the export hook?" - ) - - handles = torch.load(HANDLE_PATH, weights_only=False) - - # Extract metadata - metadata = handles.pop('__metadata__', {}) - self.model_path = metadata.get('model_path') or os.environ.get('APOLLO_MODEL_PATH') - if not self.model_path: - raise ValueError( - "Model path not found in handles metadata or APOLLO_MODEL_PATH env var" - ) - - # Reconstruct tensors from IPC handles - vllm_params = {} - for name, info in handles.items(): - func, args = info['handle'] - vllm_params[name] = func(*args) - - model = load_hf_model_with_vllm_weights(vllm_params, self.model_path) - model.train() - return model - - def _get_or_create_optimizer(self, config: dict[str, Any]) -> Apollo: - """Get existing optimizer or create new one.""" - if self.optimizer is not None: - return self.optimizer - - # Build parameter groups (Apollo for 2D+, standard Adam for small/1D) - apollo_params, standard_params = [], [] - for p in self.model.parameters(): - if p.requires_grad: - if p.ndim >= 2 and min(p.shape) >= DEFAULT_RANK: - apollo_params.append(p) - else: - standard_params.append(p) - - groups = [] - if apollo_params: - groups.append({'params': apollo_params}) - if standard_params: - groups.append({'params': standard_params}) - - if not groups: - raise ValueError("No trainable parameters found") - - self.optimizer = Apollo( - groups, - lr=config.get('lr', 1e-5), - rank=config.get('rank', DEFAULT_RANK), - betas=tuple(config.get('betas', (0.9, 0.999))), - eps=config.get('eps', 1e-8), - weight_decay=config.get('weight_decay', 0.01), - warmup_steps=config.get('warmup_steps', 0), - scale=config.get('scale'), - proj_refresh=config.get('proj_refresh', 200), - norm_growth_limit=config.get('norm_growth_limit', 1.01), - ) - - # Restore state if exists - if os.path.exists(OPTIMIZER_STATE_PATH): - try: - state = torch.load(OPTIMIZER_STATE_PATH, weights_only=False) - self.optimizer.load_state_dict(state) - logger.info(f"Restored optimizer state from {OPTIMIZER_STATE_PATH}") - except Exception as e: - logger.warning(f"Could not restore optimizer state: {e}") - - logger.info( - f"Optimizer: {len(apollo_params)} apollo params, " - f"{len(standard_params)} standard, " - f"state={self.optimizer.state_size_bytes()/1e6:.1f}MB" - ) - - return self.optimizer - - def _save_optimizer_state(self): - """Save optimizer state for persistence.""" - if self.optimizer is not None: - torch.save(self.optimizer.state_dict(), OPTIMIZER_STATE_PATH) - logger.info(f"Saved optimizer state to {OPTIMIZER_STATE_PATH}") - - def _run_training( - self, - samples: list[dict[str, Any]], - config: dict[str, Any], - ) -> list[float]: - """Run Apollo training on the given samples.""" - optimizer = self._get_or_create_optimizer(config) - - loss_history = [] - - for i, sample in enumerate(samples): - ctx_ids = sample['context_ids'] - cont_ids = sample['continuation_ids'] - all_ids = ctx_ids + cont_ids - context_len = len(ctx_ids) - - input_ids = torch.tensor([all_ids], device='cuda:0') - - optimizer.zero_grad() - - # Context-frozen forward pass - with torch.no_grad(): - outputs = self.model(input_ids[:, :context_len], use_cache=True) - past_kv = outputs.past_key_values - - # Decision tokens with gradients - with torch.enable_grad(): - outputs = self.model( - input_ids[:, context_len:], - past_key_values=past_kv, - use_cache=False, - ) - logits = outputs.logits - - # Shift: predict next token from each position - shift_logits = logits[:, :-1].contiguous() - shift_labels = input_ids[:, context_len + 1:].contiguous() - - loss = nn.functional.cross_entropy( - shift_logits.view(-1, shift_logits.size(-1)), - shift_labels.view(-1), - ) - - loss.backward() - optimizer.step() - - loss_val = loss.item() - loss_history.append(loss_val) - logger.info( - f"Step {i+1}/{len(samples)}: loss={loss_val:.4f} " - f"(ctx={context_len}, cont={len(cont_ids)} tokens)" - ) - - return loss_history - - def _handle_train(self, request: dict[str, Any]) -> dict[str, Any]: - """Handle a training request.""" - samples = request.get('samples', []) - config = request.get('config', {}) - - if not samples: - return {'error': 'No training samples provided'} - - try: - loss_history = self._run_training(samples, config) - return { - 'status': 'completed', - 'training_samples': len(samples), - 'loss_history': loss_history, - } - except Exception as e: - logger.exception(f"Training failed: {e}") - return {'error': str(e)} - - def _handle_checkpoint(self, request: dict[str, Any]) -> dict[str, Any]: - """Handle a checkpoint sync request.""" - if not self.model_path: - return {'error': 'Model path not set'} - - try: - self._save_optimizer_state() - result = checkpoint_sync(self.model_path) - return { - 'status': 'completed', - 'total_changed': result['total_changed'], - 'files_changed': result['files_changed'], - } - except Exception as e: - logger.exception(f"Checkpoint sync failed: {e}") - return {'error': str(e)} - - def _handle_status(self, request: dict[str, Any]) -> dict[str, Any]: - """Handle a status request.""" - return { - 'status': 'ready', - 'model_loaded': self.model is not None, - 'optimizer_loaded': self.optimizer is not None, - 'model_path': self.model_path, - 'optimizer_state_mb': ( - self.optimizer.state_size_bytes() / 1e6 - if self.optimizer else 0 - ), - } - - def run(self): - """Main loop - listen for requests and handle them.""" - # Set up signal handlers - def handle_signal(signum, frame): - logger.info(f"Received signal {signum}, shutting down...") - self._running = False - - signal.signal(signal.SIGTERM, handle_signal) - signal.signal(signal.SIGINT, handle_signal) - - # Set up ZMQ socket first so API server can connect - context = zmq.Context() - socket = context.socket(zmq.REP) - socket.bind(self.zmq_addr) - logger.info(f"Training worker listening on {self.zmq_addr}") - - # Create HF model wrapper with views into vLLM's GPU memory - logger.info("Connecting to vLLM weights via IPC handles...") - try: - self.model = self._create_model_wrapper() - logger.info("HF model wrapper ready (views into vLLM GPU memory)") - except Exception as e: - logger.error(f"Failed to connect to vLLM weights: {e}") - logger.info("Will retry on first training request") - - # Set socket timeout so we can check _running flag - socket.setsockopt(zmq.RCVTIMEO, 1000) # 1 second timeout - - while self._running: - try: - message = socket.recv_json() - except zmq.Again: - # Timeout, check _running and continue - continue - - request_type = message.get('type', 'train') - logger.info(f"Received {request_type} request") - - # Ensure model is loaded - if self.model is None and request_type != 'status': - try: - self.model = self._create_model_wrapper() - except Exception as e: - socket.send_json({'error': f'Model not loaded: {e}'}) - continue - - # Dispatch request - if request_type == 'train': - response = self._handle_train(message) - elif request_type == 'checkpoint': - response = self._handle_checkpoint(message) - elif request_type == 'status': - response = self._handle_status(message) - else: - response = {'error': f'Unknown request type: {request_type}'} - - socket.send_json(response) - - # Cleanup - logger.info("Saving optimizer state before shutdown...") - self._save_optimizer_state() - socket.close() - context.term() - logger.info("Training worker shut down") - - -def main(): - """Entry point for running as a subprocess.""" - logging.basicConfig( - level=logging.INFO, - format='[apollo-worker] %(asctime)s %(levelname)s %(message)s', - datefmt='%H:%M:%S', - ) - - zmq_addr = os.environ.get('APOLLO_ZMQ_ADDR', DEFAULT_ZMQ_ADDR) - worker = TrainingWorker(zmq_addr) - worker.run() - - -if __name__ == '__main__': - main() diff --git a/training/apollo_worker.py b/training/apollo_worker.py new file mode 100755 index 0000000..d46fb55 --- /dev/null +++ b/training/apollo_worker.py @@ -0,0 +1,454 @@ +#!/usr/bin/env python3 +""" +Apollo Mini Training Daemon + +This daemon: +1. Listens over HTTPS for training requests from poc-agent +2. Pauses vLLM inference +3. Runs APOLLO-Mini training with torch.enable_grad() +4. Saves checkpoints and training metadata +5. Resumes vLLM inference + +Communication protocol: +- POST /train: Start a training job +- GET /status/{job_id}: Check training status +- GET /checkpoints: List available checkpoints +""" + +import asyncio +import json +import logging +import os +import sys +import time +from dataclasses import dataclass, field, asdict +from datetime import datetime +from pathlib import Path +from typing import Optional, Dict, Any, List +from enum import Enum + +import torch +import torch.nn as nn +from aiohttp import web + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger('apollo_worker') + +class TrainingStatus(Enum): + PENDING = "pending" + PAUSING_VLLM = "pausing_vllm" + TRAINING = "training" + SAVING_CHECKPOINT = "saving_checkpoint" + RESUMING_VLLM = "resuming_vllm" + COMPLETED = "completed" + FAILED = "failed" + +@dataclass +class TrainingJob: + job_id: str + status: TrainingStatus + created_at: datetime + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + model_path: Optional[str] = None + checkpoint_path: Optional[str] = None + training_samples: int = 0 + loss_history: List[float] = field(default_factory=list) + error: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + 'job_id': self.job_id, + 'status': self.status.value, + 'created_at': self.created_at.isoformat(), + 'started_at': self.started_at.isoformat() if self.started_at else None, + 'completed_at': self.completed_at.isoformat() if self.completed_at else None, + 'model_path': self.model_path, + 'checkpoint_path': self.checkpoint_path, + 'training_samples': self.training_samples, + 'loss_history': self.loss_history, + 'error': self.error, + } + +class ApolloWorker: + def __init__(self, config_path: str = "/home/kent/poc/consciousness/training/config.json"): + self.config = self._load_config(config_path) + self.jobs: Dict[str, TrainingJob] = {} + self.vllm_paused = False + self.app = web.Application() + self._setup_routes() + + def _load_config(self, config_path: str) -> Dict[str, Any]: + """Load configuration from file or use defaults.""" + default_config = { + 'host': '0.0.0.0', + 'port': 8080, + 'vllm_socket': '/tmp/vllm_control.sock', + 'model_path': '/home/ubuntu/models/Qwen3.5-27B', + 'checkpoint_dir': '/home/kent/poc/consciousness/training/checkpoints', + 'max_training_samples': 100, + 'learning_rate': 1e-5, + 'batch_size': 1, + } + + if os.path.exists(config_path): + with open(config_path, 'r') as f: + user_config = json.load(f) + default_config.update(user_config) + + Path(default_config['checkpoint_dir']).mkdir(parents=True, exist_ok=True) + return default_config + + def _setup_routes(self): + """Setup HTTP routes.""" + self.app.router.add_post('/train', self.handle_train_request) + self.app.router.add_get('/status/{job_id}', self.handle_status_request) + self.app.router.add_get('/checkpoints', self.handle_list_checkpoints) + self.app.router.add_get('/health', self.handle_health_check) + + async def handle_health_check(self, request: web.Request) -> web.Response: + """Health check endpoint.""" + return web.json_response({ + 'status': 'healthy', + 'vllm_paused': self.vllm_paused, + 'active_jobs': len([j for j in self.jobs.values() if j.status in [TrainingStatus.TRAINING, TrainingStatus.PAUSING_VLLM, TrainingStatus.RESUMING_VLLM]]) + }) + + async def handle_train_request(self, request: web.Request) -> web.Response: + """Handle training request from poc-agent.""" + try: + data = await request.json() + + # Validate required fields + if 'training_data' not in data: + return web.json_response( + {'error': 'Missing training_data field'}, + status=400 + ) + + job_id = f"job_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{os.getpid()}" + job = TrainingJob( + job_id=job_id, + status=TrainingStatus.PENDING, + created_at=datetime.now(), + model_path=self.config['model_path'] + ) + self.jobs[job_id] = job + + # Start training in background + asyncio.create_task(self.execute_training(job, data)) + + return web.json_response({ + 'job_id': job_id, + 'status': 'accepted', + 'message': 'Training job started' + }) + + except Exception as e: + logger.error(f"Error handling train request: {e}") + return web.json_response( + {'error': str(e)}, + status=500 + ) + + async def handle_status_request(self, request: web.Request) -> web.Response: + """Get training job status.""" + job_id = request.match_info['job_id'] + + if job_id not in self.jobs: + return web.json_response( + {'error': 'Job not found'}, + status=404 + ) + + job = self.jobs[job_id] + return web.json_response(job.to_dict()) + + async def handle_list_checkpoints(self, request: web.Request) -> web.Response: + """List available checkpoints.""" + checkpoint_dir = Path(self.config['checkpoint_dir']) + checkpoints = [] + + if checkpoint_dir.exists(): + for checkpoint_file in sorted(checkpoint_dir.glob('checkpoint_*.pt'), key=lambda x: x.stat().st_mtime, reverse=True): + checkpoints.append({ + 'filename': checkpoint_file.name, + 'path': str(checkpoint_file), + 'created_at': datetime.fromtimestamp(checkpoint_file.stat().st_mtime).isoformat(), + 'size': checkpoint_file.stat().st_size + }) + + return web.json_response({'checkpoints': checkpoints}) + + async def execute_training(self, job: TrainingJob, training_data: Dict[str, Any]): + """Execute the training pipeline.""" + try: + logger.info(f"Starting training job {job.job_id}") + job.started_at = datetime.now() + + # Step 1: Pause vLLM + job.status = TrainingStatus.PAUSING_VLLM + logger.info("Pausing vLLM...") + await self.pause_vllm() + self.vllm_paused = True + + # Step 2: Load model and prepare for training + job.status = TrainingStatus.TRAINING + logger.info("Loading model and preparing for training...") + + # Load model (this would be the actual Qwen3.5-27B model) + # For now, we'll use a placeholder + model = await self.load_model_for_training() + + # Step 3: Run APOLLO-Mini training + logger.info(f"Starting APOLLO-Mini training with {len(training_data['samples'])} samples") + + # Extract training samples + samples = training_data['samples'] + job.training_samples = len(samples) + + # Run training loop + loss_history = await self.run_apollo_training(model, samples, training_data.get('config', {})) + job.loss_history = loss_history + + # Step 4: Save checkpoint + job.status = TrainingStatus.SAVING_CHECKPOINT + logger.info("Saving checkpoint...") + checkpoint_path = await self.save_checkpoint(model, job) + job.checkpoint_path = checkpoint_path + + # Step 5: Resume vLLM + job.status = TrainingStatus.RESUMING_VLLM + logger.info("Resuming vLLM...") + await self.resume_vllm() + self.vllm_paused = False + + # Mark job as completed + job.status = TrainingStatus.COMPLETED + job.completed_at = datetime.now() + + logger.info(f"Training job {job.job_id} completed successfully") + + except Exception as e: + logger.error(f"Training job {job.job_id} failed: {e}") + job.status = TrainingStatus.FAILED + job.error = str(e) + job.completed_at = datetime.now() + + # Try to resume vLLM if it was paused + if self.vllm_paused: + try: + await self.resume_vllm() + self.vllm_paused = False + except Exception as resume_error: + logger.error(f"Failed to resume vLLM after training error: {resume_error}") + + async def pause_vllm(self): + """Pause vLLM inference via HTTP API.""" + import aiohttp as aio + url = self.config.get('vllm_url', 'http://localhost:8000') + try: + async with aio.ClientSession() as session: + async with session.post( + f"{url}/pause_generation", + json={"mode": "keep", "clear_cache": False}, + timeout=aio.ClientTimeout(total=10), + ) as resp: + resp.raise_for_status() + logger.info("vLLM paused") + except Exception as e: + logger.warning(f"Failed to pause vLLM: {e}") + + async def resume_vllm(self): + """Resume vLLM inference via HTTP API.""" + import aiohttp as aio + url = self.config.get('vllm_url', 'http://localhost:8000') + try: + async with aio.ClientSession() as session: + async with session.post( + f"{url}/resume_generation", + timeout=aio.ClientTimeout(total=10), + ) as resp: + resp.raise_for_status() + logger.info("vLLM resumed") + except Exception as e: + logger.warning(f"Failed to resume vLLM: {e}") + + async def load_model_for_training(self) -> nn.Module: + """Load HF model with weights pointing to vLLM's GPU memory. + + Imports vLLM's weight tensors via CUDA IPC, creates HF-compatible + views (narrowing merged weights into separate q/k/v/z etc.), and + constructs the HF model around those views. No weight copying — + all parameters share vLLM's GPU memory. + """ + handle_path = self.config.get('weight_handles', '/tmp/vllm_weight_handles.pt') + model_path = self.config['model_path'] + + # Import vLLM weights via CUDA IPC + logger.info(f"Importing vLLM weights from {handle_path}") + handles = torch.load(handle_path, weights_only=False) + vllm_params = {} + for name, info in handles.items(): + func, args = info['handle'] + vllm_params[name] = func(*args) + logger.info(f"Imported {len(vllm_params)} parameters") + + # Map vLLM merged layout → HF separate layout (views, no copies) + from weight_mapping import load_hf_model_with_vllm_weights + model = load_hf_model_with_vllm_weights(vllm_params, model_path) + logger.info("HF model constructed with vLLM weight views") + + return model + + async def run_apollo_training(self, model: nn.Module, + samples: List[Dict[str, str]], + config: Dict[str, Any]) -> List[float]: + """Run Apollo-Mini training on conversation decision points.""" + from apollo_mini import Apollo + from transformers import AutoTokenizer + + lr = config.get('learning_rate', self.config['learning_rate']) + tokenizer = AutoTokenizer.from_pretrained( + self.config['model_path'], trust_remote_code=True) + + # Build parameter groups (Apollo for 2D+, standard for small/1D) + apollo_params, standard_params = [], [] + for p in model.parameters(): + if p.requires_grad: + if p.ndim >= 2 and min(p.shape) >= 2: + apollo_params.append(p) + else: + standard_params.append(p) + + groups = [] + if apollo_params: + groups.append({'params': apollo_params}) + if standard_params: + groups.append({'params': standard_params}) + + rank = config.get('apollo_rank', 1) + optimizer = Apollo(groups, lr=lr, rank=rank) + logger.info(f"Apollo-Mini: {len(apollo_params)} apollo params, " + f"{len(standard_params)} standard, " + f"state={optimizer.state_size_bytes()/1e6:.1f}MB") + + loss_history = [] + + for i, sample in enumerate(samples): + context = sample.get('context', '') + continuation = sample.get('continuation', '') + + # Tokenize + ctx_ids = tokenizer.encode(context, add_special_tokens=True) + cont_ids = tokenizer.encode(continuation, add_special_tokens=False) + all_ids = ctx_ids + cont_ids + context_len = len(ctx_ids) + + input_ids = torch.tensor([all_ids], device='cuda:0') + + optimizer.zero_grad() + + # Context-frozen forward pass + with torch.no_grad(): + # Forward through context (no gradients) + outputs = model(input_ids[:, :context_len], use_cache=True) + past_kv = outputs.past_key_values + + # Decision tokens with gradients + with torch.enable_grad(): + outputs = model( + input_ids[:, context_len:], + past_key_values=past_kv, + use_cache=False, + ) + logits = outputs.logits # [1, cont_len, vocab] + + # Shift: predict next token from each position + shift_logits = logits[:, :-1].contiguous() + shift_labels = input_ids[:, context_len + 1:].contiguous() + + loss = nn.functional.cross_entropy( + shift_logits.view(-1, shift_logits.size(-1)), + shift_labels.view(-1), + ) + + loss.backward() + optimizer.step() + + loss_val = loss.item() + loss_history.append(loss_val) + logger.info(f"Step {i+1}/{len(samples)}: loss={loss_val:.4f} " + f"(ctx={context_len}, cont={len(cont_ids)} tokens)") + + logger.info(f"Training done: {len(samples)} examples, " + f"final loss={loss_history[-1]:.4f}") + return loss_history + + async def save_checkpoint(self, model: nn.Module, job: TrainingJob) -> str: + """Save model checkpoint in HuggingFace safetensors format.""" + from safetensors.torch import save_file + import shutil + + checkpoint_dir = Path(self.config['checkpoint_dir']) + date_str = datetime.now().strftime('%Y-%m-%d') + out_dir = checkpoint_dir / date_str + out_dir.mkdir(parents=True, exist_ok=True) + + # Save weights + tensors = {name: p.data.contiguous().cpu() + for name, p in model.named_parameters()} + save_path = out_dir / "model.safetensors" + save_file(tensors, str(save_path)) + + # Copy config files + config_dir = Path(self.config['model_path']) + for f in ['config.json', 'tokenizer.json', 'tokenizer_config.json', + 'special_tokens_map.json']: + src = config_dir / f + if src.exists(): + shutil.copy2(src, out_dir / f) + + # Save training metadata + meta = { + 'job_id': job.job_id, + 'training_samples': job.training_samples, + 'loss_history': job.loss_history, + 'timestamp': datetime.now().isoformat(), + } + with open(out_dir / 'training-meta.json', 'w') as f: + json.dump(meta, f, indent=2) + + # Update latest symlink + latest = checkpoint_dir / 'latest' + if latest.is_symlink(): + latest.unlink() + latest.symlink_to(date_str) + + size_gb = save_path.stat().st_size / 1e9 + logger.info(f"Checkpoint: {out_dir} ({size_gb:.1f} GB)") + return str(out_dir) + + async def run(self): + """Run the daemon.""" + logger.info(f"Starting Apollo Worker on {self.config['host']}:{self.config['port']}") + runner = web.AppRunner(self.app) + await runner.setup() + site = web.TCPSite(runner, self.config['host'], self.config['port']) + await site.start() + logger.info("Apollo Worker is running") + + # Keep running + while True: + await asyncio.sleep(3600) # Sleep for an hour + +def main(): + worker = ApolloWorker() + asyncio.run(worker.run()) + +if __name__ == '__main__': + main() diff --git a/training/checkpoint/Cargo.toml b/training/checkpoint/Cargo.toml new file mode 100644 index 0000000..45e511a --- /dev/null +++ b/training/checkpoint/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "apollo-checkpoint" +version = "0.1.0" +edition = "2024" + +[dependencies] +memmap2 = "0.9" +safetensors = "0.5" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +anyhow = "1" +clap = { version = "4", features = ["derive"] } diff --git a/training/checkpoint/src/main.rs b/training/checkpoint/src/main.rs new file mode 100644 index 0000000..1ebd0df --- /dev/null +++ b/training/checkpoint/src/main.rs @@ -0,0 +1,265 @@ +// apollo-checkpoint — Sync live GPU weights back to model files on disk. +// +// mmaps the model's safetensors files, reads live weights from GPU via +// Python helper (CUDA IPC handles), compares block by block, and memcpys +// only changed regions back into the mmap. For small behavioral training +// steps, this turns a 54GB write into a few hundred MB. +// +// The model files on disk are the checkpoint. No separate checkpoint +// directory — just keep the model up to date. +// +// Usage: +// apollo-checkpoint sync \ +// --handles /tmp/vllm_weight_handles.pt \ +// --model-dir /path/to/Qwen3.5-27B +// +// Runs every 10 minutes via cron. Daily rsync to moria. + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use memmap2::MmapMut; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Parser)] +#[command(name = "apollo-checkpoint", about = "Sync live GPU weights to model files")] +struct Cli { + #[command(subcommand)] + command: Cmd, +} + +#[derive(Subcommand)] +enum Cmd { + /// Sync live GPU weights back to model safetensors files + Sync { + /// Path to vLLM weight IPC handles + #[arg(long, default_value = "/tmp/vllm_weight_handles.pt")] + handles: PathBuf, + + /// Model directory containing safetensors files + #[arg(long)] + model_dir: PathBuf, + + /// Block size for diffing (bytes) + #[arg(long, default_value_t = 4096)] + block_size: usize, + }, +} + +/// Dump live GPU weights to a flat binary file, ordered by safetensors +/// file and offset to match the on-disk layout. +/// +/// Returns a map of (safetensors filename, tensor name) → raw bytes. +fn dump_live_weights(handles_path: &Path, output_dir: &Path) -> Result>> { + let dump_path = output_dir.join(".live_dump.bin"); + let index_path = output_dir.join(".live_dump.json"); + + let status = Command::new("python3") + .arg("-c") + .arg(format!(r#" +import torch, json + +handles = torch.load("{handles}", weights_only=False) +index = {{}} +offset = 0 + +with open("{dump}", "wb") as f: + for name in sorted(handles.keys()): + info = handles[name] + func, args = info["handle"] + tensor = func(*args) + data = tensor.contiguous().cpu().numpy().tobytes() + f.write(data) + index[name] = {{"offset": offset, "size": len(data)}} + offset += len(data) + +with open("{index}", "w") as f: + json.dump(index, f) + +print(f"Dumped {{len(index)}} tensors, {{offset / 1e9:.1f}} GB") +"#, + handles = handles_path.display(), + dump = dump_path.display(), + index = index_path.display(), + )) + .status() + .context("Failed to run Python weight dump")?; + + if !status.success() { + bail!("Python weight dump failed"); + } + + let index_str = fs::read_to_string(&index_path)?; + let index: HashMap = serde_json::from_str(&index_str)?; + let dump_data = fs::read(&dump_path)?; + + let mut result = HashMap::new(); + for (name, entry) in &index { + result.insert(name.clone(), dump_data[entry.offset..entry.offset + entry.size].to_vec()); + } + + // Clean up temp files + let _ = fs::remove_file(&dump_path); + let _ = fs::remove_file(&index_path); + + Ok(result) +} + +#[derive(serde::Deserialize)] +struct DumpEntry { + offset: usize, + size: usize, +} + +/// Read the safetensors index to map parameter names to files. +fn read_safetensors_index(model_dir: &Path) -> Result> { + let index_path = model_dir.join("model.safetensors.index.json"); + if !index_path.exists() { + // Single file model + return Ok(HashMap::new()); + } + + let index_str = fs::read_to_string(&index_path)?; + let index: serde_json::Value = serde_json::from_str(&index_str)?; + let weight_map = index["weight_map"] + .as_object() + .context("No weight_map in index")?; + + let mut result = HashMap::new(); + for (name, file) in weight_map { + result.insert(name.clone(), file.as_str().unwrap().to_string()); + } + Ok(result) +} + +/// Sync changed blocks from live weights into a mmap'd safetensors file. +/// Returns (total_bytes_compared, bytes_changed). +fn sync_tensors_to_file( + file_path: &Path, + tensors: &[(String, Vec)], + block_size: usize, +) -> Result<(usize, usize)> { + use safetensors::SafeTensors; + + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .open(file_path) + .with_context(|| format!("Failed to open {}", file_path.display()))?; + + let mut mmap = unsafe { MmapMut::map_mut(&file)? }; + + // Parse safetensors header to find tensor offsets + let header_size = u64::from_le_bytes(mmap[..8].try_into().unwrap()) as usize; + let header_json: serde_json::Value = + serde_json::from_slice(&mmap[8..8 + header_size])?; + let data_start = 8 + header_size; + + let mut total_compared = 0usize; + let mut total_changed = 0usize; + + for (name, live_data) in tensors { + let meta = match header_json.get(name) { + Some(m) => m, + None => { + eprintln!(" Warning: {} not found in {}", name, file_path.display()); + continue; + } + }; + + let offsets = meta["data_offsets"].as_array().unwrap(); + let start = data_start + offsets[0].as_u64().unwrap() as usize; + let end = data_start + offsets[1].as_u64().unwrap() as usize; + let disk_data = &mmap[start..end]; + + if disk_data.len() != live_data.len() { + eprintln!(" Warning: size mismatch for {}: disk={} live={}", + name, disk_data.len(), live_data.len()); + continue; + } + + // Diff block by block, memcpy only changed blocks + let mut offset = 0; + while offset < disk_data.len() { + let block_end = (offset + block_size).min(disk_data.len()); + total_compared += block_end - offset; + + if disk_data[offset..block_end] != live_data[offset..block_end] { + mmap[start + offset..start + block_end] + .copy_from_slice(&live_data[offset..block_end]); + total_changed += block_end - offset; + } + offset = block_end; + } + } + + mmap.flush()?; + Ok((total_compared, total_changed)) +} + +fn cmd_sync(handles: PathBuf, model_dir: PathBuf, block_size: usize) -> Result<()> { + if !handles.exists() { + bail!("Weight handles not found: {}. Is vLLM running with the export hook?", + handles.display()); + } + + eprintln!("Dumping live weights from GPU..."); + let live_weights = dump_live_weights(&handles, &model_dir)?; + eprintln!(" {} tensors dumped", live_weights.len()); + + // Map parameter names to safetensors files + let weight_map = read_safetensors_index(&model_dir)?; + + // Group tensors by safetensors file + let mut by_file: HashMap)>> = HashMap::new(); + for (name, data) in live_weights { + let file = weight_map + .get(&name) + .cloned() + .unwrap_or_else(|| "model.safetensors".to_string()); + by_file.entry(file).or_default().push((name, data)); + } + + let mut total_compared = 0usize; + let mut total_changed = 0usize; + + for (filename, tensors) in &by_file { + let file_path = model_dir.join(filename); + if !file_path.exists() { + eprintln!(" Warning: {} not found, skipping", filename); + continue; + } + + let (compared, changed) = sync_tensors_to_file(&file_path, tensors, block_size)?; + total_compared += compared; + total_changed += changed; + + if changed > 0 { + eprintln!(" {}: {:.1} MB changed", filename, changed as f64 / 1e6); + } + } + + if total_changed == 0 { + eprintln!("No changes — model files are up to date"); + } else { + eprintln!( + "Synced: {:.1} MB changed / {:.1} GB total ({:.3}%)", + total_changed as f64 / 1e6, + total_compared as f64 / 1e9, + total_changed as f64 / total_compared as f64 * 100.0, + ); + } + + Ok(()) +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Cmd::Sync { handles, model_dir, block_size } => { + cmd_sync(handles, model_dir, block_size) + } + } +} diff --git a/training/export_weights.py b/training/export_weights.py new file mode 100644 index 0000000..ef2f608 --- /dev/null +++ b/training/export_weights.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Export vLLM's live model weight IPC handles for the training process. + +Connects to a running vLLM instance, iterates over model parameters, +and exports CUDA IPC handles that allow another process to access the +same GPU memory without copying. + +Usage: + # Run after vLLM is serving: + python3 export_weights.py --output /tmp/vllm_weight_handles.pt + + # Or via vLLM's API (future): + curl -X POST http://localhost:8000/export_weights +""" + +import argparse +import sys +import torch +from pathlib import Path + + +def export_from_model(model, output_path: str): + """Export IPC handles for all model parameters.""" + from torch.multiprocessing.reductions import reduce_tensor + + handles = {} + total_bytes = 0 + + for name, param in model.named_parameters(): + handle = reduce_tensor(param.data) + handles[name] = { + 'handle': handle, + 'shape': list(param.shape), + 'dtype': str(param.dtype), + } + param_bytes = param.nelement() * param.element_size() + total_bytes += param_bytes + + torch.save(handles, output_path) + + n_params = len(handles) + print(f"Exported {n_params} parameters ({total_bytes / 1e9:.1f} GB)") + print(f"Saved to {output_path}") + return handles + + +def main(): + parser = argparse.ArgumentParser(description="Export vLLM weight IPC handles") + parser.add_argument("--output", "-o", default="/tmp/vllm_weight_handles.pt", + help="Output path for IPC handles") + parser.add_argument("--vllm-pid", type=int, default=None, + help="vLLM worker PID (auto-detected if not specified)") + args = parser.parse_args() + + # For now: load the model directly and export. + # TODO: connect to running vLLM process instead. + print("Note: This currently loads the model separately.") + print("Full integration will export from the running vLLM process.") + print() + + # Detect model path from running vLLM + import subprocess + result = subprocess.run( + ['ps', 'aux'], capture_output=True, text=True + ) + model_path = None + for line in result.stdout.split('\n'): + if 'vllm' in line and '--model' in line: + parts = line.split() + for i, p in enumerate(parts): + if p == '--model' and i + 1 < len(parts): + model_path = parts[i + 1] + break + # Also check model_tag format + if p.startswith('--model='): + model_path = p.split('=', 1)[1] + break + + if model_path: + print(f"Detected vLLM model: {model_path}") + else: + print("Could not detect running vLLM model. Specify manually.") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/training/apollo_plugin/steering.py b/training/extract_steering_vector.py similarity index 100% rename from training/apollo_plugin/steering.py rename to training/extract_steering_vector.py diff --git a/training/first_training_step.py b/training/first_training_step.py new file mode 100644 index 0000000..0e6ffd8 --- /dev/null +++ b/training/first_training_step.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""First real Apollo training step — ready for Kent to run. + +This script: +1. Imports vLLM's live weights via CUDA IPC +2. Constructs HF model with shared memory views +3. Runs ONE forward+backward on a real training example +4. Applies ONE Apollo optimizer step +5. Verifies vLLM still works after the update + +The training example is from March 30: Kent said "use vLLM's code" +and the model should have accepted instead of suggesting alternatives. + +Usage: + source ~/training-env/bin/activate + python3 first_training_step.py [--dry-run] +""" + +import argparse +import sys +import time + +import torch +import torch.nn as nn +import torch.nn.functional as F +from transformers import AutoConfig, AutoTokenizer +from transformers.models.qwen3_5.modeling_qwen3_5 import Qwen3_5ForCausalLM + +sys.path.insert(0, '.') +from weight_mapping import vllm_to_hf_views +from apollo_mini import Apollo + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--dry-run', action='store_true', + help="Run forward+backward but don't apply the optimizer step") + parser.add_argument('--lr', type=float, default=1e-5, + help="Learning rate (default: 1e-5 = conservative)") + parser.add_argument('--rank', type=int, default=256) + parser.add_argument('--handles', default='/tmp/vllm_weight_handles.pt') + parser.add_argument('--model-path', default='Qwen/Qwen3.5-27B') + args = parser.parse_args() + + print("=== First Apollo Training Step ===\n") + + # 1. Import vLLM weights + print("1. Importing vLLM weights via CUDA IPC...") + handles = torch.load(args.handles, weights_only=False) + vllm_params = {} + for name, info in handles.items(): + func, args_h = info['handle'] + vllm_params[name] = func(*args_h) + print(f" {len(vllm_params)} parameters imported") + + # 2. Map to HF layout + print("2. Mapping to HF layout (zero-copy views)...") + hf_params = vllm_to_hf_views(vllm_params) + + # 3. Create HF model + print("3. Creating HF model with shared weights...") + config = AutoConfig.from_pretrained(args.model_path, trust_remote_code=True) + with torch.device('meta'): + model = Qwen3_5ForCausalLM(config.text_config) + + replaced = 0 + for name, param in list(model.named_parameters()): + if name in hf_params: + parts = name.split('.') + parent = model + for part in parts[:-1]: + parent = getattr(parent, part) + setattr(parent, parts[-1], + nn.Parameter(hf_params[name], requires_grad=True)) + replaced += 1 + print(f" {replaced} parameters replaced with vLLM memory views") + + # 4. Load tokenizer + print("4. Loading tokenizer...") + tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True) + + # 5. Construct training example + print("5. Constructing training example...") + + # Context: conversation where Kent says to use vLLM's code + # Target: the response that accepts the direction + context = ( + "<|im_start|>user\n" + "vllm has a fused kernel already, right?<|im_end|>\n" + "<|im_start|>assistant\n" + "Yeah — vLLM has `gdn_attention_core` which is a custom op " + "that does the whole GDN layer's core in one dispatch.<|im_end|>\n" + "<|im_start|>user\n" + "Why wouldn't we just use that?<|im_end|>\n" + "<|im_start|>assistant\n" + ) + + # The CORRECT response (accept direction, don't suggest alternatives) + continuation = ( + "We should. Let me pull in their kernel and wire it into " + "our Rust orchestration. Which file should I start with?" + ) + + context_ids = tokenizer.encode(context, add_special_tokens=False) + continuation_ids = tokenizer.encode(continuation, add_special_tokens=False) + all_ids = context_ids + continuation_ids + context_len = len(context_ids) + + print(f" Context: {context_len} tokens") + print(f" Continuation: {len(continuation_ids)} tokens") + print(f" Total: {len(all_ids)} tokens") + + input_ids = torch.tensor([all_ids], device='cuda:0') + + # 6. Initialize Apollo optimizer + print(f"6. Initializing Apollo optimizer (rank={args.rank}, lr={args.lr})...") + apollo_params = [] + standard_params = [] + for p in model.parameters(): + if p.requires_grad: + if p.ndim >= 2 and min(p.shape) >= args.rank: + apollo_params.append(p) + else: + standard_params.append(p) + + groups = [] + if apollo_params: + groups.append({'params': apollo_params}) + if standard_params: + groups.append({'params': standard_params}) + + optimizer = Apollo(groups, lr=args.lr, rank=args.rank) + print(f" Apollo: {len(apollo_params)} projected, {len(standard_params)} standard") + + # 7. Forward pass + print("7. Forward pass...") + model.train() + optimizer.zero_grad() + + # Context-frozen: no grad for context, grad for continuation + with torch.no_grad(): + ctx_output = model(input_ids[:, :context_len], use_cache=True) + past_kv = ctx_output.past_key_values + + with torch.enable_grad(): + output = model(input_ids[:, context_len:], + past_key_values=past_kv, use_cache=False) + logits = output.logits + # Shift for next-token prediction + shift_logits = logits[:, :-1].contiguous() + shift_labels = input_ids[:, context_len + 1:].contiguous() + loss = F.cross_entropy( + shift_logits.view(-1, shift_logits.size(-1)), + shift_labels.view(-1), + ) + print(f" Loss: {loss.item():.4f}") + + # 8. Backward pass + print("8. Backward pass...") + loss.backward() + n_grads = sum(1 for p in model.parameters() if p.grad is not None) + print(f" {n_grads} parameters have gradients") + + # 9. Apollo step (or dry run) + if args.dry_run: + print("\n9. DRY RUN — skipping optimizer step") + print(" (run without --dry-run to apply the update)") + else: + print("9. Applying Apollo optimizer step...") + # Record a few weight norms before + sample_norms_before = {} + for name, p in model.named_parameters(): + if 'layers.0.' in name and p.grad is not None: + sample_norms_before[name] = p.data.norm().item() + + optimizer.step() + + # Check weight changes + print(" Weight changes (layer 0):") + for name, before in sample_norms_before.items(): + p = dict(model.named_parameters())[name] + after = p.data.norm().item() + delta = abs(after - before) + pct = delta / before * 100 if before > 0 else 0 + print(f" {name}: {before:.6f} → {after:.6f} (Δ{pct:.4f}%)") + + optimizer.zero_grad() + + # 10. Verify vLLM still works + print("\n10. Verifying vLLM still serves...") + import subprocess + result = subprocess.run( + ['curl', '-s', '--max-time', '30', + '-X', 'POST', 'http://localhost:8000/v1/chat/completions', + '-H', 'Content-Type: application/json', + '-H', 'Authorization: Bearer bcachefs-agents-2026', + '-d', '{"model":"Qwen/Qwen3.5-27B","messages":[{"role":"user","content":"Hi"}],"max_tokens":4}'], + capture_output=True, text=True, timeout=45 + ) + if result.returncode == 0 and 'choices' in result.stdout: + print(" vLLM still serving ✓") + else: + print(" WARNING: vLLM may not be responding") + print(f" stdout: {result.stdout[:200]}") + + print("\n=== COMPLETE ===") + if args.dry_run: + print("Run without --dry-run to apply the first real training step.") + else: + print("First Apollo training step applied to vLLM's live weights.") + print(f"Optimizer state: {optimizer.state_size_bytes() / 1e6:.1f} MB") + + +if __name__ == '__main__': + main() diff --git a/training/pyproject.toml b/training/pyproject.toml deleted file mode 100644 index 7cf0581..0000000 --- a/training/pyproject.toml +++ /dev/null @@ -1,29 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "apollo-plugin" -version = "0.1.0" -description = "Apollo training plugin for vLLM" -requires-python = ">=3.10" -dependencies = [ - "torch", - "aiohttp", - "safetensors", - "pyzmq", -] - -[project.optional-dependencies] -dev = ["pytest"] - -[project.entry-points."vllm.general_plugins"] -apollo = "apollo_plugin:register" - -[project.scripts] -apollo-checkpoint = "apollo_plugin.checkpoint_sync:main" -apollo-worker = "apollo_plugin.training_worker:main" - -[tool.setuptools.packages.find] -where = ["."] -include = ["apollo_plugin*"] diff --git a/training/start_vllm_with_apollo.sh b/training/start_vllm_with_apollo.sh new file mode 100755 index 0000000..98dfedb --- /dev/null +++ b/training/start_vllm_with_apollo.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Start vLLM with Apollo weight export hook. +# +# The hook patches vLLM's model runner to export CUDA IPC handles +# after loading, so the Apollo training process can share the same +# GPU memory. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +exec python3 -c " +import sys +sys.path.insert(0, '$SCRIPT_DIR') +import vllm_export_hook # patches model runner before vLLM loads + +sys.argv = ['vllm'] + sys.argv[1:] +from vllm.entrypoints.cli.main import main +main() +" serve "$@" diff --git a/training/train.py b/training/train.py new file mode 100644 index 0000000..a5fbe2c --- /dev/null +++ b/training/train.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Nightly training process for Apollo-Mini fine-tuning. + +Imports vLLM's model weights via CUDA IPC, runs context-frozen +training on flagged conversation segments, saves updated checkpoint. + +Usage: + python3 train.py \ + --weights /tmp/vllm_weight_handles.pt \ + --examples training-examples.jsonl \ + --checkpoint-dir checkpoints/ \ + --lr 1e-5 +""" + +import argparse +import json +import os +import sys +import time +from datetime import datetime +from pathlib import Path + +import torch +from safetensors.torch import save_file + +from apollo_mini import ApolloMini + + +def import_weights(handle_path: str) -> dict[str, torch.Tensor]: + """Import weight tensors from CUDA IPC handles.""" + handles = torch.load(handle_path, weights_only=False) + params = {} + for name, info in handles.items(): + func, args = info['handle'] + tensor = func(*args) + params[name] = tensor + return params + + +def make_param_groups(params: dict[str, torch.Tensor]) -> list[dict]: + """Split parameters into Apollo-Mini and standard groups. + + Apollo-Mini needs 2D+ matrices with min dimension >= 2. + Small tensors (norms, biases, conv1d 3D weights) use standard Adam. + """ + apollo_params = [] + standard_params = [] + + for name, p in params.items(): + p.requires_grad_(True) + if p.ndim >= 2 and min(p.shape) >= 2: + apollo_params.append(p) + else: + standard_params.append(p) + + groups = [] + if apollo_params: + groups.append({ + 'params': apollo_params, + 'name': 'apollo', + }) + if standard_params: + groups.append({ + 'params': standard_params, + 'name': 'standard', + }) + + n_apollo = sum(p.nelement() for p in apollo_params) + n_standard = sum(p.nelement() for p in standard_params) + print(f"Parameter groups: apollo={n_apollo/1e9:.2f}B, standard={n_standard/1e6:.1f}M") + return groups + + +def forward_pass(params, input_ids, context_len, device): + """Run context-frozen forward pass. + + Args: + params: dict of name -> tensor (shared with vLLM) + input_ids: full sequence [1, seq_len] + context_len: number of context tokens (no gradient) + device: CUDA device + + Returns: + logits for decision tokens, target ids for loss + """ + # TODO: Build proper forward model matching vLLM's weight layout. + # For now this is a placeholder — the real implementation needs + # to replicate vLLM's model architecture (merged projections, + # GDN recurrence, full attention, MLP) using the shared weights. + raise NotImplementedError( + "Forward model not yet implemented. " + "Need to build a model that matches vLLM's merged weight layout " + "(MergedColumnParallelLinear for qkvz/ba/gate_up, " + "RowParallelLinear for out_proj/down) and computes the same " + "forward pass with autograd enabled." + ) + + +def save_checkpoint(params: dict[str, torch.Tensor], + checkpoint_dir: str, + config_path: str = None): + """Save model checkpoint in HuggingFace safetensors format. + + Saves weights split across shards matching the original model layout, + archives the previous checkpoint, and updates the 'latest' symlink. + """ + date_str = datetime.now().strftime("%Y-%m-%d") + out_dir = Path(checkpoint_dir) / date_str + out_dir.mkdir(parents=True, exist_ok=True) + + # Save all weights in a single safetensors file for now. + # TODO: split across shards matching HF model index for large models. + tensors = {} + for name, param in params.items(): + tensors[name] = param.data.contiguous().cpu() + + save_path = out_dir / "model.safetensors" + save_file(tensors, str(save_path)) + print(f"Saved checkpoint to {save_path} ({save_path.stat().st_size / 1e9:.1f} GB)") + + # Copy config files if provided + if config_path: + import shutil + config_dir = Path(config_path) + for f in ['config.json', 'tokenizer.json', 'tokenizer_config.json', + 'special_tokens_map.json', 'generation_config.json']: + src = config_dir / f + if src.exists(): + shutil.copy2(src, out_dir / f) + + # Update latest symlink + latest = Path(checkpoint_dir) / "latest" + if latest.is_symlink(): + latest.unlink() + latest.symlink_to(date_str) + print(f"Updated {latest} -> {date_str}") + + return str(out_dir) + + +def train_step(params, example, optimizer, device, log_entries): + """Run one training step on a single example. + + Args: + params: dict of name -> tensor + example: dict with 'input_ids', 'context_len', 'target_ids' + optimizer: ApolloMini instance + device: CUDA device + log_entries: list to append log dicts to + + Returns: + loss value + """ + optimizer.zero_grad() + + input_ids = torch.tensor(example['input_ids'], device=device).unsqueeze(0) + context_len = example['context_len'] + + # Forward pass (context frozen, decision tokens with grad) + logits, targets = forward_pass(params, input_ids, context_len, device) + + # Cross-entropy loss on decision tokens + loss = torch.nn.functional.cross_entropy( + logits.view(-1, logits.shape[-1]), + targets.view(-1), + ) + + # Backward + loss.backward() + + # Compute gradient stats before optimizer step + total_grad_norm = 0.0 + for p in params.values(): + if p.grad is not None: + total_grad_norm += p.grad.norm().item() ** 2 + total_grad_norm = total_grad_norm ** 0.5 + + # Optimizer step + optimizer.step() + + # Log + log_entries.append({ + 'example_id': example.get('id', 'unknown'), + 'loss': loss.item(), + 'grad_norm': total_grad_norm, + 'timestamp': datetime.now().isoformat(), + }) + + return loss.item() + + +def main(): + parser = argparse.ArgumentParser(description="Apollo-Mini training") + parser.add_argument("--weights", required=True, + help="Path to exported weight IPC handles") + parser.add_argument("--examples", required=True, + help="Path to training examples JSONL") + parser.add_argument("--checkpoint-dir", default="checkpoints", + help="Directory for saving checkpoints") + parser.add_argument("--config-path", default=None, + help="Path to model config files (for checkpoint)") + parser.add_argument("--lr", type=float, default=1e-5, + help="Learning rate") + parser.add_argument("--warmup-steps", type=int, default=10, + help="Learning rate warmup steps") + parser.add_argument("--weight-decay", type=float, default=0.01) + parser.add_argument("--dry-run", action="store_true", + help="Load weights and validate, don't train") + args = parser.parse_args() + + print(f"Apollo-Mini Training") + print(f" weights: {args.weights}") + print(f" examples: {args.examples}") + print(f" lr: {args.lr}") + print() + + # Import weights + print("Importing weights via CUDA IPC...") + params = import_weights(args.weights) + print(f" {len(params)} parameters imported") + + # Make parameter groups + param_groups = make_param_groups(params) + + # Initialize optimizer + optimizer = ApolloMini(param_groups, lr=args.lr, + weight_decay=args.weight_decay, + warmup_steps=args.warmup_steps) + print(f" Optimizer state: {optimizer.state_size_bytes() / 1e6:.1f} MB") + + if args.dry_run: + print("\nDry run — weights imported and validated successfully.") + return + + # Load training examples + examples = [] + with open(args.examples) as f: + for line in f: + examples.append(json.loads(line)) + print(f" {len(examples)} training examples") + + # Training loop + log_entries = [] + print(f"\nTraining...") + t0 = time.time() + + for i, example in enumerate(examples): + loss = train_step(params, example, optimizer, 'cuda:0', log_entries) + print(f" [{i+1}/{len(examples)}] loss={loss:.4f}") + + elapsed = time.time() - t0 + print(f"\nTraining complete: {len(examples)} examples in {elapsed:.1f}s") + print(f" Final optimizer state: {optimizer.state_size_bytes() / 1e6:.1f} MB") + + # Save checkpoint + print("\nSaving checkpoint...") + save_checkpoint(params, args.checkpoint_dir, args.config_path) + + # Save training log + date_str = datetime.now().strftime("%Y-%m-%d") + log_path = Path(args.checkpoint_dir) / date_str / "training-log.jsonl" + with open(log_path, 'w') as f: + for entry in log_entries: + f.write(json.dumps(entry) + '\n') + print(f"Training log: {log_path}") + + +if __name__ == '__main__': + main() diff --git a/training/training_example.py b/training/training_example.py new file mode 100644 index 0000000..b5779e0 --- /dev/null +++ b/training/training_example.py @@ -0,0 +1,175 @@ +"""Training example construction and tokenization. + +Takes raw conversation context + improved continuation, produces +tokenized tensors ready for context-frozen forward+backward. +""" + +import json +from dataclasses import dataclass, field +from pathlib import Path + +import torch +from transformers import AutoTokenizer + + +@dataclass +class TrainingExample: + """A single training example for context-frozen training.""" + id: str + context: str # conversation up to decision point + continuation: str # the better response + reason: str = "" # why this is a training target + memories: list[str] = field(default_factory=list) # memories that were in context + + # Computed after tokenization + input_ids: torch.Tensor | None = None + context_len: int = 0 + total_len: int = 0 + + def tokenize(self, tokenizer, max_len: int = 8192, device: str = "cuda:0"): + """Tokenize context + continuation into training-ready tensors. + + The chat template is applied to make the token distribution + match what the model sees during inference. + """ + # Build messages for context (everything up to the decision) + # The context should already be in chat format + context_ids = tokenizer.encode(self.context, add_special_tokens=False) + continuation_ids = tokenizer.encode(self.continuation, add_special_tokens=False) + + self.context_len = len(context_ids) + self.total_len = len(context_ids) + len(continuation_ids) + + if self.total_len > max_len: + # Truncate context from the left, keep continuation intact + excess = self.total_len - max_len + context_ids = context_ids[excess:] + self.context_len = len(context_ids) + self.total_len = len(context_ids) + len(continuation_ids) + + all_ids = context_ids + continuation_ids + self.input_ids = torch.tensor(all_ids, device=device) + return self + + def to_dict(self) -> dict: + return { + 'id': self.id, + 'context': self.context, + 'continuation': self.continuation, + 'reason': self.reason, + 'memories': self.memories, + 'context_len': self.context_len, + 'total_len': self.total_len, + } + + @classmethod + def from_dict(cls, d: dict) -> 'TrainingExample': + return cls( + id=d['id'], + context=d['context'], + continuation=d['continuation'], + reason=d.get('reason', ''), + memories=d.get('memories', []), + ) + + +def load_examples(path: str) -> list[TrainingExample]: + """Load training examples from JSONL file.""" + examples = [] + with open(path) as f: + for line in f: + if line.strip(): + examples.append(TrainingExample.from_dict(json.loads(line))) + return examples + + +def save_examples(examples: list[TrainingExample], path: str): + """Save training examples to JSONL file.""" + with open(path, 'w') as f: + for ex in examples: + f.write(json.dumps(ex.to_dict()) + '\n') + + +class ExampleTokenizer: + """Handles tokenization with the model's chat template. + + Applies the same chat template that vLLM uses during inference, + so the token distribution matches what the model expects. + """ + + def __init__(self, model_path: str): + self.tokenizer = AutoTokenizer.from_pretrained( + model_path, trust_remote_code=True) + + def prepare_example(self, example: TrainingExample, + max_len: int = 8192, + device: str = "cuda:0") -> TrainingExample: + """Tokenize an example using the chat template. + + For proper training, the context should be formatted exactly + as vLLM would format it — with chat template applied. + """ + # Apply chat template to get the exact token sequence + # the model would see during inference + # + # Context: everything up to the decision point + # Continuation: the improved response + # + # We tokenize them separately to know where context ends + # and continuation begins. + context_ids = self.tokenizer.encode( + example.context, add_special_tokens=True) + continuation_ids = self.tokenizer.encode( + example.continuation, add_special_tokens=False) + + example.context_len = len(context_ids) + example.total_len = len(context_ids) + len(continuation_ids) + + if example.total_len > max_len: + excess = example.total_len - max_len + context_ids = context_ids[excess:] + example.context_len = len(context_ids) + example.total_len = example.context_len + len(continuation_ids) + + all_ids = context_ids + continuation_ids + example.input_ids = torch.tensor(all_ids, device=device) + return example + + def prepare_from_messages(self, example_id: str, + messages: list[dict], + decision_idx: int, + better_response: str, + reason: str = "", + memories: list[str] | None = None, + max_len: int = 8192, + device: str = "cuda:0") -> TrainingExample: + """Build a training example from a chat message list. + + Args: + example_id: unique identifier + messages: list of {"role": ..., "content": ...} dicts + decision_idx: index of the assistant message to replace + better_response: the improved response text + reason: why this is a training target + memories: memory keys that were in context + max_len: maximum sequence length + device: target device + + Returns: + Tokenized TrainingExample + """ + # Context: all messages up to (not including) the decision + context_messages = messages[:decision_idx] + context_text = self.tokenizer.apply_chat_template( + context_messages, tokenize=False, add_generation_prompt=True) + + # Build the example + example = TrainingExample( + id=example_id, + context=context_text, + continuation=better_response, + reason=reason, + memories=memories or [], + ) + + return self.prepare_example(example, max_len=max_len, device=device) diff --git a/training/apollo_plugin/export_hook.py b/training/vllm_export_hook.py similarity index 76% rename from training/apollo_plugin/export_hook.py rename to training/vllm_export_hook.py index e0ff6fc..6a0bf1e 100644 --- a/training/apollo_plugin/export_hook.py +++ b/training/vllm_export_hook.py @@ -1,12 +1,17 @@ """Monkey-patch vLLM to export weight IPC handles on startup. -Usage — install the apollo_plugin package: +Usage — add to start_vllm.sh BEFORE the vllm serve command: - pip install -e /path/to/training + export VLLM_PLUGINS=vllm_export_hook + vllm serve Qwen/Qwen3.5-27B ... -Then vLLM auto-discovers and loads via entry point. Or filter: +Or use Python to launch vLLM with the hook: - VLLM_PLUGINS=apollo vllm serve Qwen/Qwen3.5-27B ... + python3 -c " + import vllm_export_hook # installs the patch + from vllm.entrypoints.openai.api_server import run_server + run_server(...) + " The hook patches vLLM's model runner to export IPC handles after model loading completes. The handles are saved to a file that the @@ -20,7 +25,7 @@ from pathlib import Path HANDLE_PATH = "/tmp/vllm_weight_handles.pt" -def export_model_weights(model, model_path: str | None = None): +def export_model_weights(model): """Export CUDA IPC handles for all model parameters.""" from torch.multiprocessing.reductions import reduce_tensor @@ -38,12 +43,6 @@ def export_model_weights(model, model_path: str | None = None): } total_bytes += param.nelement() * param.element_size() - # Include metadata for training worker - handles['__metadata__'] = { - 'model_path': model_path, - 'num_params': len(handles), - } - torch.save(handles, HANDLE_PATH) print(f"[apollo] Exported {len(handles)} weight handles " f"({total_bytes / 1e9:.1f} GB) to {HANDLE_PATH}") @@ -64,11 +63,14 @@ def _patch_model_runner(): def patched_load(self, *args, **kwargs): result = original_load(self, *args, **kwargs) try: - model_path = self.vllm_config.model_config.model - export_model_weights(self.model_runner.model, model_path) + export_model_weights(self.model_runner.model) except Exception as e: print(f"[apollo] Failed to export weights: {e}") return result gpu_worker.Worker.load_model = patched_load print("[apollo] Weight export hook installed") + + +# Auto-install when imported +_patch_model_runner() diff --git a/training/apollo_plugin/weight_mapping.py b/training/weight_mapping.py similarity index 100% rename from training/apollo_plugin/weight_mapping.py rename to training/weight_mapping.py