From 32e81b51fbc6aea597903a941e0b7ab8cb61e427 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 3 Oct 2026 00:04:03 -0700 Subject: [PATCH] loopd: compaction threshold and the fallback summary Co-Authored-By: ornith-1.5-35b-a3b --- config/compact.md | 8 ++++ crates/loopd/src/compact.rs | 32 +++++++++++++ crates/loopd/src/lib.rs | 1 + crates/loopd/tests/compact_due.rs | 79 +++++++++++++++++++++++++++++++ docs/implementer-log.md | 1 + 5 files changed, 121 insertions(+) create mode 100644 config/compact.md create mode 100644 crates/loopd/src/compact.rs create mode 100644 crates/loopd/tests/compact_due.rs diff --git a/config/compact.md b/config/compact.md new file mode 100644 index 0000000..b468535 --- /dev/null +++ b/config/compact.md @@ -0,0 +1,8 @@ +Summarise this conversation for the next epoch of the same session. + +Write a short account of: +- what the owner asked for and decided +- facts that were established +- open threads and anything still waiting + +Do not use tools. Do not address the owner. Do not invent facts that are not in the messages. diff --git a/crates/loopd/src/compact.rs b/crates/loopd/src/compact.rs new file mode 100644 index 0000000..9e5a971 --- /dev/null +++ b/crates/loopd/src/compact.rs @@ -0,0 +1,32 @@ +//! Compaction: when a session starts a new epoch, the summary call, and writing the next files +//! (M5d spec, section 3). Later tasks fill the call and the write. + +use bxcore::{LogRecord, Usage}; + +/// True when `cache_n + prompt_n` is at or above `threshold * n_ctx`. +pub fn due(usage: &Usage, threshold: f64, n_ctx: u64) -> bool { + let total = usage.cache_n.saturating_add(usage.prompt_n) as f64; + total >= threshold * (n_ctx as f64) +} + +/// The epoch's `User` texts, newest last, joined with `"\n\n"`, then `cap_chars`. +pub fn fallback_summary(records: &[LogRecord], summary_chars: usize) -> String { + let contents: Vec<&str> = records + .iter() + .filter_map(|r| match r { + LogRecord::User { content, .. } => Some(content.as_str()), + _ => None, + }) + .collect(); + let joined = contents + .iter() + .rev() + .cloned() + .collect::>() + .join("\n\n"); + cap_chars(&joined, summary_chars) +} + +pub(crate) fn cap_chars(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} diff --git a/crates/loopd/src/lib.rs b/crates/loopd/src/lib.rs index a3d6fd0..d36ff8a 100644 --- a/crates/loopd/src/lib.rs +++ b/crates/loopd/src/lib.rs @@ -3,6 +3,7 @@ pub mod baseline; pub mod broker_port; pub mod channel; +pub mod compact; pub mod config; pub mod http; pub mod llama; diff --git a/crates/loopd/tests/compact_due.rs b/crates/loopd/tests/compact_due.rs new file mode 100644 index 0000000..53b6edd --- /dev/null +++ b/crates/loopd/tests/compact_due.rs @@ -0,0 +1,79 @@ +//! Tests for when compaction is due and for the fallback summary. Do not edit. + +use bxcore::{Epoch, Hash32, LogRecord, SessionId, Timestamp, Usage}; +use loopd::compact::{due, fallback_summary}; + +fn usage(cache_n: u64, prompt_n: u64) -> Usage { + Usage { + cache_n, + prompt_n, + predicted_n: 0, + reasoning_tokens: 0, + thinking_capped: false, + } +} + +fn user(content: &str) -> LogRecord { + LogRecord::User { + time: Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap(), + content: content.to_string(), + speaker: None, + } +} + +#[test] +fn due_at_the_threshold_and_not_below() { + assert!(!due(&usage(400, 449), 0.85, 1_000), "849 < 850"); + assert!(due(&usage(400, 450), 0.85, 1_000), "850 == 0.85 * 1000"); + assert!(due(&usage(0, 851), 0.85, 1_000)); + assert!(!due(&usage(0, 0), 0.85, 1_000)); +} + +#[test] +fn due_uses_this_agents_n_ctx() { + // 0.85 * 262144 = 222822.4, so 222823 is the first integer at or above. + assert!(!due(&usage(0, 222_822), 0.85, 262_144)); + assert!(due(&usage(0, 222_823), 0.85, 262_144)); + assert!(due(&usage(200_000, 22_823), 0.85, 262_144)); +} + +#[test] +fn a_threshold_of_one_is_the_full_window() { + assert!(!due(&usage(99, 0), 1.0, 100)); + assert!(due(&usage(100, 0), 1.0, 100)); +} + +#[test] +fn fallback_is_user_texts_newest_last() { + let records = vec![ + LogRecord::SessionStart { + time: Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap(), + session: SessionId::new("a").unwrap(), + epoch: Epoch(0), + slot: 0, + baseline: Hash32::ZERO, + }, + user("first"), + user("second"), + user("third"), + ]; + assert_eq!( + fallback_summary(&records, 4_000), + "third\n\nsecond\n\nfirst" + ); +} + +#[test] +fn fallback_is_cut_to_summary_chars_on_a_character_boundary() { + let records = vec![user("café"), user("ab")]; + assert_eq!(fallback_summary(&records, 3), "ab\n"); + assert_eq!(fallback_summary(&records, 1), "a"); + assert_eq!(fallback_summary(&records, 0), ""); + let long = user("ååå"); + assert_eq!(fallback_summary(&[long], 2), "åå"); +} + +#[test] +fn fallback_with_no_users_is_empty() { + assert_eq!(fallback_summary(&[], 100), ""); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 5947d05..7a61d3c 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5d1/05-loopd-compact-due | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/compact_due.rs` and the starter `config/compact.md` from `docs/plans/M5d1/files/`. Created `crates/loopd/src/compact.rs`: `due` does `cache_n.saturating_add(prompt_n) as f64 >= threshold * (n_ctx as f64)`; `fallback_summary` `filter_map`s `LogRecord::User { content, .. }` to `&str`, reverses, joins `"\n\n"`, then `cap_chars`; `cap_chars` is the given `chars().take(n)`. Added `pub mod compact;` after `pub mod channel;` in `lib.rs` (rustfmt order, accepted). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test compact_due` passes all 6. `git diff --stat` lists only `lib.rs` and `compact.rs`; `git status --short` shows `compact_due.rs` and `compact.md` as new. `make gate` printed `gate: ok` with 1490 tests (1484 before, plus the 6 of `compact_due.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d1/04-memoryd-honcho-epochs | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_epochs.rs` (new). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed exactly the named failure: `honcho_epochs` with one E0432 (`unresolved import memoryd::honcho::turns::read_session`); every other target compiled. Made H1 (`turns.rs`: `log_path` doc now names the sibling epoch files and that `read_session` concatenates them), H1b (inserted `epoch_logs` after `read_records`: lists `.jsonl` under `sessions//`, `n` parsed as `u32`, sorted by `n`, a missing directory empty; then `read_session` concatenates `read_records` over each), H2 (`resume` reads `read_session` instead of `read_log(&log_path(...))`), H3 (`records` matches `read_session(...).map_err(|e| e.to_string())`, keeping the logged line shape); W1 (`worker.rs`: added `use ...decide::{final_reply, turn_records, user_text}` and `use ...turns::{LogError, TurnSync, log_path, read_session}`), W2 (`send_turn` loads `read_session`, `self.readable(&path)` on Ok, `LogError::Damaged(_)` -> `Unsent::Local("log")`, other error -> `Unsent::Unreadable(e.to_string())`, then `turn_records`/`user_text`/`final_reply` from `decide`), W3 (`batch` called with `text`/`reply` by value, no borrow). `read_turn` no longer used in the worker body but stays re-exported. `cargo check -p memoryd` clean, no error or warning. `wc -l` prints 369 for `turns.rs` and 453 for `worker.rs`, each ≤ 500. `cargo test -p memoryd --test honcho_epochs --test honcho_turns --test honcho_worker` passes: 6 in `honcho_epochs`, 16 in `honcho_turns`, 12 in `honcho_worker`. `git diff --stat` lists only `turns.rs` and `worker.rs`; `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1484 tests (1478 before, plus the 6 of `honcho_epochs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d1/03-loopd-carry-messages | 2026-10-02 | done | 2 | pass | none | Copied the given test `crates/loopd/tests/carry_messages.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `carry_messages` with one E0432 (`unresolved import loopd::baseline::{CARRY_HEADING, messages_with_carry}`); every other target compiled. Made B1 (`baseline.rs`: `CARRY_HEADING` = `concat!("Earlier ", "turns, summarised:")`, `messages` becomes `messages_with_carry(baseline, records, None)`, new `messages_with_carry` inserts the heading plus a blank line plus the summary as one `ChatMessage::User` after the system prompt and before walking `records`; the rest of the `match` untouched); T1 (`turn.rs`: `use crate::baseline::messages_with_carry`); T2 (`run_turn` passes `session.carry()`). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test carry_messages --test baseline --test turn` passes: 5 in `carry_messages`, 6 in `baseline`, 6 in `turn`. `git diff --stat` lists only `baseline.rs` and `turn.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1478 tests (1473 before, plus the 5 of `carry_messages.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d1/02-loopd-session-epochs | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/session_epochs.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failures: `session_epochs` with E0599 (`no method named epoch` / `carry` / `slot`) and E0433 (`NoCarry`); every other target compiled and the existing `session` tests still compiled. Made S1 (module doc naming `.baseline.json` / `.jsonl` and epochs after compaction), S2 (`SessionError` gains `NoCarry { path: PathBuf }`), S3 (its Display arm naming the damaged-log runbook pointer), S4 (`Session` gains `epoch`, `slot`, `carry`, `prior_users`, `prior_untrusted` in that order), S5 (`create` inits the five new fields, `slot` from the argument), S6 (whole `open`: `complete_epochs` picks the highest epoch with both files, `records` is the current log, `priors` walks every earlier complete epoch for user/untrusted counts and the epoch-1 carry, `max_call` over every epoch for `next_call`, `live_slot` from the current `SessionStart`), S7 (accessors `epoch` / `slot` / `carry`), S8 (`untrusted` = `prior_untrusted || any_untrusted`, `turns` = `prior_users.saturating_add(count_users)`), S9 (`log_path` to the current epoch's file plus helpers `complete_epochs`, `load_records`, `priors`, `carry_from`, `count_users`, `any_untrusted`, `max_call`, `live_slot`). `cargo check -p loopd` clean, no error or warning. `wc -l crates/loopd/src/session.rs` prints 402. `cargo test -p loopd --test session_epochs --test session` passes: 9 in `session_epochs`, 7 in `session`. `git diff --stat` lists only `session.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1473 tests (1472 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +9 over the 1464 before is exactly the tests of `session_epochs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |