From aa7f251dad17fdb8bd00dd4f7c959d005f7902a1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 14 Jul 2026 07:35:28 +0400 Subject: [PATCH 1/2] feat(tree): flavoured tree kind + ask-steered summarisation Add the ask-driven "flavoured" tree family (issue #68): a variant of the source/topic/global bucket-seal trees whose every summarisation step is steered by a natural-language ask. - TreeKind::Flavoured + "flavoured" wire string; SummaryTreeKind::Flavoured with wiki/summaries/flavoured-/ path routing and front-matter. - Persist the ask on mem_tree_trees via a nullable `ask` column (schema + add_column_if_missing migration); thread through Tree.ask, insert/row_to_tree, and every SELECT. - get_or_create_tree_with_ask stamps the ask at create time. - SummaryContext.ask, populated from the tree row at seal; prepare_summary_prompt emits a flavour-directed profile prompt when an ask is present. - TreeFactory::flavoured(scope, ask) with LabelStrategy::Empty. - TreeConfig::flavour_root_token_budget (default 1000). Claude-Session: https://claude.ai/code/session_01TvyrqfH6dBqs7D6XFdSRRP --- src/memory/chunks/connection.rs | 3 ++ src/memory/chunks/schema.rs | 3 +- src/memory/chunks/store_embed_tests.rs | 1 + src/memory/config.rs | 17 +++++++ src/memory/retrieval/test_support.rs | 1 + src/memory/store/content/compose/summary.rs | 8 ++++ src/memory/store/content/paths.rs | 6 +++ src/memory/sync/rebuild.rs | 1 + src/memory/tree/bucket_seal.rs | 4 ++ src/memory/tree/bucket_seal_label_tests.rs | 2 + src/memory/tree/direct_ingest.rs | 1 + src/memory/tree/factory.rs | 51 +++++++++++++++++++-- src/memory/tree/factory_tests.rs | 1 + src/memory/tree/io_tests.rs | 1 + src/memory/tree/read_tests.rs | 1 + src/memory/tree/registry.rs | 16 +++++++ src/memory/tree/registry_tests.rs | 1 + src/memory/tree/store/store_tests.rs | 1 + src/memory/tree/store/trees.rs | 15 +++--- src/memory/tree/store/types.rs | 12 +++++ src/memory/tree/summarise.rs | 24 +++++++++- src/memory/tree/summarise_tests.rs | 2 + 22 files changed, 158 insertions(+), 14 deletions(-) diff --git a/src/memory/chunks/connection.rs b/src/memory/chunks/connection.rs index a7f06fb..d0a7770 100644 --- a/src/memory/chunks/connection.rs +++ b/src/memory/chunks/connection.rs @@ -260,6 +260,9 @@ fn apply_schema(conn: &Connection) -> Result<()> { )?; add_column_if_missing(conn, "mem_tree_jobs", "failure_reason", "TEXT")?; add_column_if_missing(conn, "mem_tree_jobs", "failure_class", "TEXT")?; + // Ask/flavour instruction for flavoured trees (issue #68). Nullable; NULL for + // source/topic/global trees. + add_column_if_missing(conn, "mem_tree_trees", "ask", "TEXT")?; Ok(()) } diff --git a/src/memory/chunks/schema.rs b/src/memory/chunks/schema.rs index f13eb24..d63a329 100644 --- a/src/memory/chunks/schema.rs +++ b/src/memory/chunks/schema.rs @@ -154,7 +154,8 @@ CREATE TABLE IF NOT EXISTS mem_tree_trees ( max_level INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active', created_at_ms INTEGER NOT NULL, - last_sealed_at_ms INTEGER + last_sealed_at_ms INTEGER, + ask TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_mem_tree_trees_kind_scope diff --git a/src/memory/chunks/store_embed_tests.rs b/src/memory/chunks/store_embed_tests.rs index 3f6e00f..8984b97 100644 --- a/src/memory/chunks/store_embed_tests.rs +++ b/src/memory/chunks/store_embed_tests.rs @@ -97,6 +97,7 @@ fn summary_reembed_tombstone_roundtrips_and_clears() { status: TreeStatus::Active, created_at: now, last_sealed_at: None, + ask: None, }, ) .unwrap(); diff --git a/src/memory/config.rs b/src/memory/config.rs index a4fcb90..a3c80c9 100644 --- a/src/memory/config.rs +++ b/src/memory/config.rs @@ -17,6 +17,10 @@ pub const OUTPUT_TOKEN_BUDGET: u32 = 5_000; pub const SUMMARY_FANOUT: u32 = 10; /// Default flush age for stale buffers (7 days, in seconds). pub const DEFAULT_FLUSH_AGE_SECS: u64 = 7 * 24 * 60 * 60; +/// Default token budget for a flavoured tree's compiled root markdown artifact. +/// The compiled profile is clamped to this so it can be dropped verbatim into a +/// prompt as a small, always-fresh style/preference guide (issue #68). +pub const FLAVOUR_ROOT_TOKEN_BUDGET: u32 = 1_000; /// Fixed embedding dimension used by OpenHuman. pub const DEFAULT_EMBEDDING_DIM: usize = 768; /// Folder reader per-file size cap (10 MB). @@ -119,6 +123,18 @@ pub struct TreeConfig { /// Age, in seconds, after which an unsealed buffer is force-flushed /// (see [`DEFAULT_FLUSH_AGE_SECS`]). pub flush_age_secs: u64, + /// Token budget for a [`TreeKind::Flavoured`](crate::memory::tree::TreeKind::Flavoured) + /// tree's compiled root markdown artifact (see [`FLAVOUR_ROOT_TOKEN_BUDGET`]). + /// The compiled profile body is clamped to this before it is staged. + #[serde(default = "default_flavour_root_token_budget")] + pub flavour_root_token_budget: u32, +} + +/// Serde default for [`TreeConfig::flavour_root_token_budget`] so configs +/// deserialised from older payloads (without the field) keep the 1000-token +/// default instead of `0`. +fn default_flavour_root_token_budget() -> u32 { + FLAVOUR_ROOT_TOKEN_BUDGET } impl Default for TreeConfig { @@ -128,6 +144,7 @@ impl Default for TreeConfig { output_token_budget: OUTPUT_TOKEN_BUDGET, summary_fanout: SUMMARY_FANOUT, flush_age_secs: DEFAULT_FLUSH_AGE_SECS, + flavour_root_token_budget: FLAVOUR_ROOT_TOKEN_BUDGET, } } } diff --git a/src/memory/retrieval/test_support.rs b/src/memory/retrieval/test_support.rs index 4826131..5c64bb3 100644 --- a/src/memory/retrieval/test_support.rs +++ b/src/memory/retrieval/test_support.rs @@ -143,6 +143,7 @@ pub fn source_tree(id: &str, scope: &str, root_id: Option<&str>, max_level: u32) status: TreeStatus::Active, created_at: ts, last_sealed_at: Some(ts), + ask: None, } } diff --git a/src/memory/store/content/compose/summary.rs b/src/memory/store/content/compose/summary.rs index 4a909f7..5d64320 100644 --- a/src/memory/store/content/compose/summary.rs +++ b/src/memory/store/content/compose/summary.rs @@ -66,6 +66,7 @@ fn build_summary_front_matter(r: &SummaryComposeInput<'_>) -> String { SummaryTreeKind::Source => "source", SummaryTreeKind::Global => "global", SummaryTreeKind::Topic => "topic", + SummaryTreeKind::Flavoured => "flavoured", }; let trs = r.time_range_start.to_rfc3339(); @@ -156,6 +157,13 @@ fn build_summary_alias(r: &SummaryComposeInput<'_>) -> String { r.level, entity, r.child_count ) } + SummaryTreeKind::Flavoured => { + let flavour = scope_short_label(r.tree_scope); + format!( + "L{} \u{00b7} flavour {} \u{00b7} {} children \u{00b7} {}", + r.level, flavour, r.child_count, date_range + ) + } } } diff --git a/src/memory/store/content/paths.rs b/src/memory/store/content/paths.rs index c90fd7b..9427328 100644 --- a/src/memory/store/content/paths.rs +++ b/src/memory/store/content/paths.rs @@ -25,6 +25,9 @@ pub enum SummaryTreeKind { Global, /// Per-topic (entity) tree. Layout: `wiki/summaries/topic-/L/.md` Topic, + /// Ask-driven flavoured tree. Layout: + /// `wiki/summaries/flavoured-/L/.md`. + Flavoured, } /// Top-level directory for derived/wiki content (summaries, etc.). @@ -52,6 +55,9 @@ pub fn summary_rel_path( SummaryTreeKind::Topic => { format!("{WIKI_PREFIX}/summaries/topic-{scope_slug}/L{level}/{filename}.md") } + SummaryTreeKind::Flavoured => { + format!("{WIKI_PREFIX}/summaries/flavoured-{scope_slug}/L{level}/{filename}.md") + } } } diff --git a/src/memory/sync/rebuild.rs b/src/memory/sync/rebuild.rs index 39e0573..73e5e8c 100644 --- a/src/memory/sync/rebuild.rs +++ b/src/memory/sync/rebuild.rs @@ -200,6 +200,7 @@ pub async fn rebuild_tree_from_raw_with_audit( tree_kind: TreeKind::Source, target_level: 1, token_budget: config.tree.output_token_budget, + ask: tree.ask.as_deref(), }; let call = match summariser .summarise_with_usage(&summary_inputs, &context) diff --git a/src/memory/tree/bucket_seal.rs b/src/memory/tree/bucket_seal.rs index 119c56f..3dd5f8e 100644 --- a/src/memory/tree/bucket_seal.rs +++ b/src/memory/tree/bucket_seal.rs @@ -359,6 +359,7 @@ pub async fn seal_one_level_with_services( tree_kind: tree.kind, target_level, token_budget: budget, + ask: tree.ask.as_deref(), }; // Treat a blank summary the same as a hard error — fall back to the // deterministic concat so we never persist `content = ""`. @@ -418,6 +419,7 @@ pub async fn seal_one_level_with_services( crate::memory::tree::TreeKind::Source => SummaryTreeKind::Source, crate::memory::tree::TreeKind::Topic => SummaryTreeKind::Topic, crate::memory::tree::TreeKind::Global => SummaryTreeKind::Global, + crate::memory::tree::TreeKind::Flavoured => SummaryTreeKind::Flavoured, }; let child_basenames = if target_level == 1 { Some( @@ -717,6 +719,7 @@ async fn seal_explicit_children( tree_kind: tree.kind, target_level, token_budget: config.tree.output_token_budget, + ask: tree.ask.as_deref(), }; match services.summariser.summarise(&inputs, &context).await { Ok(output) if !output.content.trim().is_empty() => output, @@ -761,6 +764,7 @@ async fn seal_explicit_children( crate::memory::tree::TreeKind::Source => SummaryTreeKind::Source, crate::memory::tree::TreeKind::Topic => SummaryTreeKind::Topic, crate::memory::tree::TreeKind::Global => SummaryTreeKind::Global, + crate::memory::tree::TreeKind::Flavoured => SummaryTreeKind::Flavoured, }; let doc_slug = slugify_source_id(doc_id); let staged = stage_summary_with_layout( diff --git a/src/memory/tree/bucket_seal_label_tests.rs b/src/memory/tree/bucket_seal_label_tests.rs index ac1fbaf..85281f4 100644 --- a/src/memory/tree/bucket_seal_label_tests.rs +++ b/src/memory/tree/bucket_seal_label_tests.rs @@ -251,6 +251,7 @@ async fn topic_tree_seal_persists_topic_kind_not_source() { status: TreeStatus::Active, created_at: Utc::now(), last_sealed_at: None, + ask: None, }; store::insert_tree(&cfg, &tree).unwrap(); let s = ConcatSummariser::new(); @@ -323,6 +324,7 @@ async fn hydrate_summary_inputs_preserves_order_and_skips_missing() { status: TreeStatus::Active, created_at: ts, last_sealed_at: None, + ask: None, }; store::insert_tree(&cfg, &tree).unwrap(); diff --git a/src/memory/tree/direct_ingest.rs b/src/memory/tree/direct_ingest.rs index dedaeb4..e8bca97 100644 --- a/src/memory/tree/direct_ingest.rs +++ b/src/memory/tree/direct_ingest.rs @@ -69,6 +69,7 @@ pub async fn ingest_summary( crate::memory::tree::TreeKind::Source => SummaryTreeKind::Source, crate::memory::tree::TreeKind::Topic => SummaryTreeKind::Topic, crate::memory::tree::TreeKind::Global => SummaryTreeKind::Global, + crate::memory::tree::TreeKind::Flavoured => SummaryTreeKind::Flavoured, }; let staged = stage_summary( &content_root, diff --git a/src/memory/tree/factory.rs b/src/memory/tree/factory.rs index 3737fbf..a0f4ed2 100644 --- a/src/memory/tree/factory.rs +++ b/src/memory/tree/factory.rs @@ -14,7 +14,7 @@ use crate::memory::config::MemoryConfig; use crate::memory::score::extract::CompositeExtractor; use crate::memory::tree::bucket_seal::{append_leaf, LabelStrategy, LeafRef}; use crate::memory::tree::flush::force_flush_tree; -use crate::memory::tree::registry::get_or_create_tree; +use crate::memory::tree::registry::get_or_create_tree_with_ask; use crate::memory::tree::store::{archive_tree, Tree, TreeKind}; use crate::memory::tree::summarise::Summariser; @@ -30,6 +30,10 @@ pub enum TreeProfile { Topic, /// Singleton cross-source tree; scope is always [`GLOBAL_SCOPE`]. Global, + /// Ask-driven flavoured tree: scope is the ask slug; every seal is steered + /// by the tree's persisted `ask` and the root compiles into a prompt-ready + /// markdown profile. See [`TreeFactory::flavoured`]. + Flavoured, } /// Factory/config object for one tree instance. @@ -37,6 +41,9 @@ pub enum TreeProfile { pub struct TreeFactory<'a> { profile: TreeProfile, scope: Cow<'a, str>, + /// Natural-language ask stamped on a freshly-created [`TreeProfile::Flavoured`] + /// tree. `None` for every other profile. + ask: Option>, } impl<'a> TreeFactory<'a> { @@ -45,6 +52,7 @@ impl<'a> TreeFactory<'a> { Self { profile: TreeProfile::Source, scope: scope.into(), + ask: None, } } @@ -53,6 +61,7 @@ impl<'a> TreeFactory<'a> { Self { profile: TreeProfile::Topic, scope: scope.into(), + ask: None, } } @@ -62,16 +71,40 @@ impl<'a> TreeFactory<'a> { Self { profile: TreeProfile::Global, scope: Cow::Borrowed(GLOBAL_SCOPE), + ask: None, + } + } + + /// Build a [`TreeProfile::Flavoured`] factory scoped to the ask slug `scope` + /// (e.g. `"tweet-style"`), carrying the full natural-language `ask` that + /// steers every summarisation step (e.g. "Distill the author's tweet-writing + /// style: voice, tone, structure, vocabulary, punctuation habits, dos and + /// don'ts."). + /// + /// The ask is persisted on the tree row the first time the tree is created; + /// re-instantiating a factory for a `(Flavoured, scope)` that already exists + /// returns the live row and leaves its stored ask untouched. + pub fn flavoured(scope: impl Into>, ask: impl Into>) -> Self { + Self { + profile: TreeProfile::Flavoured, + scope: scope.into(), + ask: Some(ask.into()), } } /// Reconstruct the factory matching an existing [`Tree`] row, deriving the - /// profile from its [`TreeKind`] and reusing its scope. + /// profile from its [`TreeKind`] and reusing its scope. For flavoured trees + /// the row's persisted `ask` is carried back onto the factory. pub fn from_tree(tree: &'a Tree) -> Self { match tree.kind { TreeKind::Source => Self::source(tree.scope.as_str()), TreeKind::Topic => Self::topic(tree.scope.as_str()), TreeKind::Global => Self::global(), + TreeKind::Flavoured => Self { + profile: TreeProfile::Flavoured, + scope: Cow::Borrowed(tree.scope.as_str()), + ask: tree.ask.as_deref().map(Cow::Borrowed), + }, } } @@ -86,6 +119,7 @@ impl<'a> TreeFactory<'a> { TreeProfile::Source => TreeKind::Source, TreeProfile::Topic => TreeKind::Topic, TreeProfile::Global => TreeKind::Global, + TreeProfile::Flavoured => TreeKind::Flavoured, } } @@ -94,6 +128,12 @@ impl<'a> TreeFactory<'a> { self.scope.as_ref() } + /// The natural-language ask carried by a flavoured factory, or `None` for + /// every other profile. + pub fn ask(&self) -> Option<&str> { + self.ask.as_deref() + } + /// Default seal-time label strategy. Source trees re-extract entities/topics /// from synthesised summary text; topic/global trees leave labels empty /// (their scope already pins the dominant theme). @@ -102,13 +142,14 @@ impl<'a> TreeFactory<'a> { TreeKind::Source => { LabelStrategy::ExtractFromContent(Arc::new(CompositeExtractor::regex_only())) } - TreeKind::Topic | TreeKind::Global => LabelStrategy::Empty, + TreeKind::Topic | TreeKind::Global | TreeKind::Flavoured => LabelStrategy::Empty, } } - /// Look up or create the tree row in the database. + /// Look up or create the tree row in the database. For a flavoured factory + /// the [`ask`](Self::ask) is stamped on the row the first time it is created. pub fn get_or_create(&self, config: &MemoryConfig) -> Result { - get_or_create_tree(config, self.kind(), self.scope()) + get_or_create_tree_with_ask(config, self.kind(), self.scope(), self.ask()) } /// Append one leaf using this profile's default labeling policy. diff --git a/src/memory/tree/factory_tests.rs b/src/memory/tree/factory_tests.rs index e41f4e0..56e154c 100644 --- a/src/memory/tree/factory_tests.rs +++ b/src/memory/tree/factory_tests.rs @@ -80,6 +80,7 @@ fn from_tree_round_trips_kind() { status: crate::memory::tree::store::TreeStatus::Active, created_at: chrono::Utc::now(), last_sealed_at: None, + ask: None, }; let f = TreeFactory::from_tree(&tree); assert_eq!(f.kind(), TreeKind::Topic); diff --git a/src/memory/tree/io_tests.rs b/src/memory/tree/io_tests.rs index 66f4782..1558d69 100644 --- a/src/memory/tree/io_tests.rs +++ b/src/memory/tree/io_tests.rs @@ -13,6 +13,7 @@ fn sample_tree() -> Tree { status: TreeStatus::Active, created_at: Utc::now(), last_sealed_at: None, + ask: None, } } diff --git a/src/memory/tree/read_tests.rs b/src/memory/tree/read_tests.rs index 9a4d31c..0b030f5 100644 --- a/src/memory/tree/read_tests.rs +++ b/src/memory/tree/read_tests.rs @@ -74,6 +74,7 @@ fn seed_tree(cfg: &MemoryConfig) { status: TreeStatus::Active, created_at: ts(), last_sealed_at: Some(ts()), + ask: None, }; store::insert_tree(cfg, &tree).unwrap(); upsert_chunks( diff --git a/src/memory/tree/registry.rs b/src/memory/tree/registry.rs index efb8f4f..adc281b 100644 --- a/src/memory/tree/registry.rs +++ b/src/memory/tree/registry.rs @@ -13,6 +13,21 @@ use crate::memory::tree::store::{self, Tree, TreeKind, TreeStatus}; /// Generic get-or-create. Returns the existing tree for `(kind, scope)` or /// inserts a fresh one, recovering from a `UNIQUE` race by re-querying. pub fn get_or_create_tree(config: &MemoryConfig, kind: TreeKind, scope: &str) -> Result { + get_or_create_tree_with_ask(config, kind, scope, None) +} + +/// Get-or-create variant that stamps a natural-language `ask` on the row when a +/// fresh tree is inserted. Used by [`crate::memory::tree::TreeFactory::flavoured`]. +/// +/// The `ask` is only applied at insert time; an existing `(kind, scope)` row is +/// returned unchanged (its stored ask wins), so re-instantiating a flavoured +/// factory never rewrites the ask of a live tree. +pub fn get_or_create_tree_with_ask( + config: &MemoryConfig, + kind: TreeKind, + scope: &str, + ask: Option<&str>, +) -> Result { if let Some(existing) = store::get_tree_by_scope(config, kind, scope)? { return Ok(existing); } @@ -26,6 +41,7 @@ pub fn get_or_create_tree(config: &MemoryConfig, kind: TreeKind, scope: &str) -> status: TreeStatus::Active, created_at: Utc::now(), last_sealed_at: None, + ask: ask.map(str::to_string), }; match store::insert_tree(config, &tree) { Ok(()) => Ok(tree), diff --git a/src/memory/tree/registry_tests.rs b/src/memory/tree/registry_tests.rs index 64ce301..dd41193 100644 --- a/src/memory/tree/registry_tests.rs +++ b/src/memory/tree/registry_tests.rs @@ -74,6 +74,7 @@ fn get_or_create_recovers_from_unique_race() { status: TreeStatus::Active, created_at: Utc::now(), last_sealed_at: None, + ask: None, }; store::insert_tree(&cfg, &pre_existing).unwrap(); let got = get_or_create_tree(&cfg, TreeKind::Source, "slack:#eng").unwrap(); diff --git a/src/memory/tree/store/store_tests.rs b/src/memory/tree/store/store_tests.rs index c2b844b..ab3f7ea 100644 --- a/src/memory/tree/store/store_tests.rs +++ b/src/memory/tree/store/store_tests.rs @@ -29,6 +29,7 @@ fn sample_tree(id: &str, scope: &str) -> Tree { status: TreeStatus::Active, created_at: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(), last_sealed_at: None, + ask: None, } } diff --git a/src/memory/tree/store/trees.rs b/src/memory/tree/store/trees.rs index aa39b7f..af10036 100644 --- a/src/memory/tree/store/trees.rs +++ b/src/memory/tree/store/trees.rs @@ -21,8 +21,8 @@ pub fn insert_tree_conn(conn: &Connection, tree: &Tree) -> Result<()> { conn.execute( "INSERT INTO mem_tree_trees ( id, kind, scope, root_id, max_level, status, - created_at_ms, last_sealed_at_ms - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + created_at_ms, last_sealed_at_ms, ask + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ tree.id, tree.kind.as_str(), @@ -32,6 +32,7 @@ pub fn insert_tree_conn(conn: &Connection, tree: &Tree) -> Result<()> { tree.status.as_str(), tree.created_at.timestamp_millis(), tree.last_sealed_at.map(|t| t.timestamp_millis()), + tree.ask, ], ) .with_context(|| format!("Failed to insert tree id={}", tree.id))?; @@ -96,7 +97,7 @@ pub fn get_tree_by_scope_conn( ) -> Result> { let mut stmt = conn.prepare( "SELECT id, kind, scope, root_id, max_level, status, - created_at_ms, last_sealed_at_ms + created_at_ms, last_sealed_at_ms, ask FROM mem_tree_trees WHERE kind = ?1 AND scope = ?2", )?; let row = stmt @@ -111,7 +112,7 @@ pub fn get_tree(config: &MemoryConfig, id: &str) -> Result> { with_connection(config, |conn| { let mut stmt = conn.prepare( "SELECT id, kind, scope, root_id, max_level, status, - created_at_ms, last_sealed_at_ms + created_at_ms, last_sealed_at_ms, ask FROM mem_tree_trees WHERE id = ?1", )?; let row = stmt @@ -145,7 +146,7 @@ pub fn get_trees_batch( .join(","); let sql = format!( "SELECT id, kind, scope, root_id, max_level, status, - created_at_ms, last_sealed_at_ms + created_at_ms, last_sealed_at_ms, ask FROM mem_tree_trees WHERE id IN ({placeholders})" ); @@ -169,7 +170,7 @@ pub fn list_trees_by_kind(config: &MemoryConfig, kind: TreeKind) -> Result) -> rusqlite::Result { let status_s: String = row.get(5)?; let created_ms: i64 = row.get(6)?; let last_sealed_ms: Option = row.get(7)?; + let ask: Option = row.get(8)?; let kind = TreeKind::parse(&kind_s).map_err(|e| { rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, e.into()) @@ -253,5 +255,6 @@ pub(crate) fn row_to_tree(row: &rusqlite::Row<'_>) -> rusqlite::Result { status, created_at: ms_to_utc(created_ms)?, last_sealed_at: last_sealed_ms.map(ms_to_utc).transpose()?, + ask, }) } diff --git a/src/memory/tree/store/types.rs b/src/memory/tree/store/types.rs index cfe6081..0368beb 100644 --- a/src/memory/tree/store/types.rs +++ b/src/memory/tree/store/types.rs @@ -34,6 +34,11 @@ pub enum TreeKind { Topic, /// Cross-source daily digest tree. Global, + /// Ask-driven "flavoured" tree. Every summarisation step is steered by a + /// natural-language ask (persisted on [`Tree::ask`]) and the root compiles + /// into a single prompt-ready markdown profile. `scope` encodes the ask + /// slug (e.g. `"tweet-style"`). See `docs/spec/flavoured-trees.md`. + Flavoured, } impl TreeKind { @@ -43,6 +48,7 @@ impl TreeKind { Self::Source => "source", Self::Topic => "topic", Self::Global => "global", + Self::Flavoured => "flavoured", } } @@ -52,6 +58,7 @@ impl TreeKind { "source" => Ok(Self::Source), "topic" => Ok(Self::Topic), "global" => Ok(Self::Global), + "flavoured" => Ok(Self::Flavoured), other => Err(format!("unknown tree kind: {other}")), } } @@ -119,6 +126,11 @@ pub struct Tree { pub created_at: DateTime, /// Timestamp of the most recent seal, or `None` if nothing has sealed yet. pub last_sealed_at: Option>, + /// Natural-language ask that steers every summarisation step, for + /// [`TreeKind::Flavoured`] trees. `None` for source/topic/global trees, + /// which summarise with the generic folding prompt. + #[serde(default)] + pub ask: Option, } /// A sealed summary node — one level above raw leaves. diff --git a/src/memory/tree/summarise.rs b/src/memory/tree/summarise.rs index 3aa2bee..79a53fe 100644 --- a/src/memory/tree/summarise.rs +++ b/src/memory/tree/summarise.rs @@ -52,6 +52,12 @@ pub struct SummaryContext<'a> { pub target_level: u32, /// Maximum approximate tokens the produced summary may occupy. pub token_budget: u32, + /// Natural-language ask that steers the fold, for + /// [`TreeKind::Flavoured`](crate::memory::tree::TreeKind::Flavoured) trees. + /// When present, [`prepare_summary_prompt`] emits a flavour-directed system + /// prompt instead of the generic folding prompt. `None` for every other + /// tree kind. + pub ask: Option<&'a str>, } /// Output of a summarise call. @@ -123,12 +129,26 @@ pub fn prepare_summary_prompt( .filter(|language| !language.trim().is_empty()) .map(|language| format!("\nWrite the summary in {language}.")) .unwrap_or_default(); - Some(PreparedSummaryPrompt { - system: format!( + let system = match ctx.ask.map(str::trim).filter(|ask| !ask.is_empty()) { + // Flavour-directed fold: the produced summary is a running *profile* + // answering the ask, distilled from the evidence and the prior profile. + Some(ask) => format!( + "You are distilling evidence into a profile that answers this ask:\n{ask}\n\n\ + The inputs below are evidence (and, where present, the current profile). \ + Merge them into one updated profile.\n\ + Keep it prescriptive and concrete — describe the patterns, not the individual items.\n\ + Aim for ~{effective_budget} tokens or fewer.\n\ + Output only the profile prose — no preamble, no JSON, no markdown headings.{language}" + ), + // Generic fold used by source/topic/global trees. + None => format!( "You are folding multiple notes into one compact summary.\n\ Aim for ~{effective_budget} tokens or fewer. Capture key facts, decisions, and entities.\n\ Output only the summary prose — no preamble, no JSON, no markdown headings.{language}" ), + }; + Some(PreparedSummaryPrompt { + system, user, effective_budget, }) diff --git a/src/memory/tree/summarise_tests.rs b/src/memory/tree/summarise_tests.rs index 6907b1e..3f36062 100644 --- a/src/memory/tree/summarise_tests.rs +++ b/src/memory/tree/summarise_tests.rs @@ -50,6 +50,7 @@ async fn concat_summariser_matches_fallback() { tree_kind: TreeKind::Source, target_level: 1, token_budget: 10_000, + ask: None, }; let out = ConcatSummariser::new() .summarise(&inputs, &ctx) @@ -71,6 +72,7 @@ fn provider_prompt_is_priority_ordered_language_aware_and_budgeted() { tree_kind: TreeKind::Source, target_level: 1, token_budget: 9_000, + ask: None, }; let prompt = prepare_summary_prompt(&[low, high], &context, Some("French")).unwrap(); assert!(prompt.user.starts_with("[high]")); From 273afe4a89483bcf8453ac4c434a9d378e1e0f41 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 14 Jul 2026 07:36:00 +0400 Subject: [PATCH 2/2] feat(tree): compile flavoured tree root into prompt-ready markdown The deliverable for issue #68: the top of a flavoured tree compiled into a single, small, always-fresh markdown profile a host can inject verbatim. - compile_flavoured_root(config, tree_id): reads the root SummaryNode body, clamps it to flavour_root_token_budget tokens, and stages it with light YAML front-matter (ask, tree id, scope, sealed_at, leaves_folded evidence changelog, token estimate). - Staged atomically and overwritten in place at flavoured/.md so hosts read a fixed path; it is a pure projection of the root node, untracked in SQLite. - Recompiled after any seal that touches the root, via a hook in cascade_all_from_with_services (covers append_leaf, flush, force_flush/seal_now). - Unit tests, an end-to-end integration test (tests/flavoured_tree.rs), and a spec page (docs/spec/flavoured-trees.md). Claude-Session: https://claude.ai/code/session_01TvyrqfH6dBqs7D6XFdSRRP --- docs/spec/flavoured-trees.md | 133 +++++++++++++++++++++++ src/memory/tree/bucket_seal.rs | 15 +++ src/memory/tree/flavoured.rs | 165 +++++++++++++++++++++++++++++ src/memory/tree/flavoured_tests.rs | 145 +++++++++++++++++++++++++ src/memory/tree/mod.rs | 7 +- tests/flavoured_tree.rs | 108 +++++++++++++++++++ 6 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 docs/spec/flavoured-trees.md create mode 100644 src/memory/tree/flavoured.rs create mode 100644 src/memory/tree/flavoured_tests.rs create mode 100644 tests/flavoured_tree.rs diff --git a/docs/spec/flavoured-trees.md b/docs/spec/flavoured-trees.md new file mode 100644 index 0000000..3cdea7e --- /dev/null +++ b/docs/spec/flavoured-trees.md @@ -0,0 +1,133 @@ +# Flavoured memory trees + +_Spec for the ask-driven tree kind added in issue +[#68](https://github.com/tinyhumansai/tinycortex/issues/68)._ + +A **flavoured tree** is a summary tree that is instantiated with a specific +*ask/flavour* — e.g. "tweet writing style", "email tone", "coding preferences" — +and continuously distills everything ingested into it through that lens. The top +of the tree compiles into a single markdown file (≤ 1000 tokens by default) that +a host can drop directly into a prompt as a style guide / preference profile. + +It is a *flavoured* variant of the source/topic/global trees: the same +bucket-seal machinery (`src/memory/tree/`), but every summarisation step is +steered by the ask, and the root has a first-class compiled artifact instead of +being just another `SummaryNode`. + +## Contract + +### Tree kind & scope + +- Wire kind: `flavoured` (`TreeKind::Flavoured`, `src/memory/tree/store/types.rs`). +- `scope` encodes the **ask slug** (e.g. `tweet-style`, `coding-prefs`). The + `(kind, scope)` pair is unique, so one slug maps to one flavoured tree. +- The full natural-language ask (not just the slug) is persisted on the tree row + in the nullable `mem_tree_trees.ask` column and surfaced as `Tree.ask`. + +### Steered summarisation + +- `SummaryContext.ask` carries the tree's ask into each seal + (`seal_one_level_with_services` populates it from the tree row). +- When `ask` is present, `prepare_summary_prompt` + (`src/memory/tree/summarise.rs`) emits a **flavour-directed** system prompt + ("You are distilling evidence into a profile that answers this ask: …") instead + of the generic folding prompt. The prior profile is merged with new evidence at + every level, so the root stays a running, prescriptive profile. +- No LLM coupling: hosts inject their own `Summariser`; the ask rides along in + the context. The deterministic `ConcatSummariser` remains the fallback, so a + flavoured tree still functions (concatenation-with-truncation) without a + model. + +### Construction + +```rust +let factory = TreeFactory::flavoured( + "tweet-style", + "Distill the author's tweet-writing style: voice, tone, structure, \ + vocabulary, emoji/punctuation habits, and concrete dos and don'ts.", +); +let tree = factory.get_or_create(&config)?; // stamps the ask on create +factory.insert_leaf(&config, &leaf, &summariser)?; // per tweet/thread +let style_md = compile_flavoured_root(&config, &tree.id)?; // ≤ budget, prompt-ready +``` + +`TreeFactory::flavoured(scope, ask)` uses `LabelStrategy::Empty` (the ask, not +seal-time entity extraction, defines the tree). Re-instantiating a factory for an +existing `(Flavoured, scope)` returns the live row and leaves its stored ask +untouched. + +### Ingest + +No new ingest machinery: leaves arrive via the existing `append_leaf` / +`direct_ingest::ingest_summary`, or via the archivist `TreeLeafSink`. A host +routes flavour-shaped content (or whole conversations) into the flavoured tree's +sink. Cross-tree fan-in and automatic content routing are out of scope for v1. + +## Compiled root artifact (the deliverable) + +`compile_flavoured_root(config, tree_id) -> Result`: + +1. Fetches the tree, resolves its `root_id`, and reads the root `SummaryNode` + body (empty before the first seal). +2. Clamps the body to `TreeConfig::flavour_root_token_budget` tokens + (default `FLAVOUR_ROOT_TOKEN_BUDGET = 1000`). +3. Renders YAML front-matter + body and **stages it in place** at a stable path + so hosts read a fixed location. + +### Artifact format + +``` +--- +kind: flavoured_root +tree_id: "flavoured:…" +scope: "tweet-style" +ask: "Distill the author's tweet-writing style: …" +root_id: "summary:…" # or null before the first seal +sealed_at: "2026-07-14T…Z" # tree.last_sealed_at, or null +leaves_folded: 42 # root node child count (evidence changelog) +token_estimate: 812 +token_budget: 1000 +--- + +``` + +`ask`, `tree_id`, `scope`, `root_id`, and `sealed_at` are emitted as escaped +one-line YAML scalars. `leaves_folded` / `token_estimate` / `sealed_at` double as +a cache-busting evidence changelog. + +### Staged path + +- Relative: `flavoured/.md` under the content root + (`flavoured_root_rel_path`); absolute via `flavoured_root_abs_path`. +- Written atomically and **overwritten in place** on every recompile, so the + path is stable across refreshes. +- Unlike per-node summary staging, the compiled root is *not* tracked in SQLite — + it is a pure projection of the root node, safe to delete and regenerate. + +Per-node summaries of a flavoured tree are staged like any other tree under +`wiki/summaries/flavoured-/L/.md` +(`SummaryTreeKind::Flavoured`). + +### Recompile triggers + +The artifact is refreshed whenever a seal may have moved the root: +`cascade_all_from_with_services` recompiles after any non-empty cascade on a +flavoured tree, covering `append_leaf`, `flush_stale_buffers`, and +`force_flush_tree` / `TreeFactory::seal_now`. Recompilation is best-effort — a +failed compile logs and never fails the seal. + +## Config + +| Field | Default | Meaning | +| --- | --- | --- | +| `TreeConfig::flavour_root_token_budget` | `1000` | Token clamp for the compiled root body. | + +Flavoured trees accept the global per-level seal budgets (50k in / 5k out) in +v1; per-kind overrides are a future refinement for sparser style evidence. + +## Out of scope (v1) + +- In-repo LLM provider (the `Summariser` seam stays host-injected). +- The runtime markdown time-tree — flavoured trees are bucket-seal only. +- Automatic content routing/classification into flavoured trees. +- In-engine fan-out subscriptions mirroring leaves across trees. diff --git a/src/memory/tree/bucket_seal.rs b/src/memory/tree/bucket_seal.rs index 3dd5f8e..29f2eaf 100644 --- a/src/memory/tree/bucket_seal.rs +++ b/src/memory/tree/bucket_seal.rs @@ -289,6 +289,21 @@ pub async fn cascade_all_from_with_services( .await?; sealed_ids.push(summary_id); } + + // Flavoured trees carry a first-class compiled root artifact; refresh it + // whenever a seal in this cascade may have moved the root. `tree` here is a + // pre-seal snapshot, so `compile_flavoured_root` re-reads the live row to + // pick up the new `root_id`. Best-effort: a stale artifact must never fail + // an otherwise-successful seal. + if !sealed_ids.is_empty() && tree.kind == crate::memory::tree::TreeKind::Flavoured { + if let Err(err) = crate::memory::tree::flavoured::compile_flavoured_root(config, &tree.id) { + log::warn!( + "[memory_tree:flavoured] compile root failed tree_id={}: {err:#}", + tree.id + ); + } + } + Ok(sealed_ids) } diff --git a/src/memory/tree/flavoured.rs b/src/memory/tree/flavoured.rs new file mode 100644 index 0000000..c43d762 --- /dev/null +++ b/src/memory/tree/flavoured.rs @@ -0,0 +1,165 @@ +//! Compiled root artifact for ask-driven flavoured trees (issue #68). +//! +//! A [`TreeKind::Flavoured`] tree distills everything ingested into it through +//! the lens of its persisted `ask` (see [`crate::memory::tree::TreeFactory::flavoured`]). +//! The *deliverable* is the root of that tree compiled into a single, small, +//! prompt-ready markdown file: a style guide / preference profile a host can +//! inject verbatim into a system prompt. +//! +//! [`compile_flavoured_root`] fetches the tree's current root [`SummaryNode`], +//! clamps its body to [`TreeConfig::flavour_root_token_budget`] tokens, wraps it +//! in light front-matter (ask, tree id, scope, sealed-at, evidence changelog), +//! and stages it at a stable, overwritten-in-place path +//! (`flavoured/.md`) so hosts can read a fixed location. The engine +//! recompiles it after any seal that touches the root (see the hook in +//! [`crate::memory::tree::bucket_seal::cascade_all_from_with_services`]). +//! +//! Unlike the per-node summary staging in [`crate::memory::store::content`], the +//! compiled root is *not* tracked in SQLite — it is a pure projection of the +//! root node, safe to delete and regenerate at any time. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::memory::chunks::content_root; +use crate::memory::config::MemoryConfig; +use crate::memory::store::content::slugify_source_id; +use crate::memory::tree::store::{self, TreeKind}; +use crate::memory::tree::summarise::clamp_to_budget; + +/// Top-level directory (under the content root) holding compiled flavoured-root +/// artifacts, one `.md` per flavoured tree scope. +pub const FLAVOURED_ROOT_DIR: &str = "flavoured"; + +/// Relative content path (forward-slash) of the compiled root artifact for a +/// flavoured tree `scope`. Stable and overwritten in place across recompiles so +/// hosts can read a fixed location. +pub fn flavoured_root_rel_path(scope: &str) -> String { + format!("{FLAVOURED_ROOT_DIR}/{}.md", slugify_source_id(scope)) +} + +/// Absolute path of the compiled root artifact for `scope` within `config`'s +/// content root. +pub fn flavoured_root_abs_path(config: &MemoryConfig, scope: &str) -> PathBuf { + content_root(config) + .join(FLAVOURED_ROOT_DIR) + .join(format!("{}.md", slugify_source_id(scope))) +} + +/// Compile the current root of a flavoured tree into a `≤ budget`-token markdown +/// profile, stage it at [`flavoured_root_rel_path`] (overwriting any prior +/// version in place), and return the full markdown (front-matter + body). +/// +/// The body is the tree's root [`SummaryNode`] content clamped to +/// [`TreeConfig::flavour_root_token_budget`](crate::memory::config::TreeConfig::flavour_root_token_budget) +/// tokens. Before the first seal (`root_id == None`) the body is empty; the +/// artifact is still written so hosts always find the fixed path. +/// +/// # Errors +/// Returns `Err` if `tree_id` does not exist, if it is not a +/// [`TreeKind::Flavoured`] tree, if the root node cannot be read, or if the +/// artifact cannot be written to disk. +pub fn compile_flavoured_root(config: &MemoryConfig, tree_id: &str) -> Result { + let tree = store::get_tree(config, tree_id)? + .ok_or_else(|| anyhow::anyhow!("no tree with id {tree_id}"))?; + if tree.kind != TreeKind::Flavoured { + anyhow::bail!( + "compile_flavoured_root: tree {tree_id} is {}, not flavoured", + tree.kind.as_str() + ); + } + + // Pull the root node body (empty until the first seal emits an L1 node). + let (body, leaves_folded, root_id) = match tree.root_id.as_deref() { + Some(root_id) => { + let node = store::get_summary(config, root_id)?.ok_or_else(|| { + anyhow::anyhow!("flavoured tree {tree_id} root {root_id} missing") + })?; + ( + node.content, + node.child_ids.len(), + Some(root_id.to_string()), + ) + } + None => (String::new(), 0, None), + }; + + let budget = config.tree.flavour_root_token_budget; + let (clamped, token_estimate) = clamp_to_budget(body.trim(), budget); + + let markdown = render_flavoured_root(&FlavouredRootMeta { + tree_id: &tree.id, + scope: &tree.scope, + ask: tree.ask.as_deref(), + root_id: root_id.as_deref(), + sealed_at: tree.last_sealed_at.map(|t| t.to_rfc3339()), + leaves_folded, + token_estimate, + token_budget: budget, + body: &clamped, + }); + + let abs = flavoured_root_abs_path(config, &tree.scope); + if let Some(parent) = abs.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create flavoured root dir {}", parent.display()))?; + } + crate::memory::fsutil::atomic_write(&abs, markdown.as_bytes()) + .with_context(|| format!("write flavoured root {}", abs.display()))?; + + Ok(markdown) +} + +/// Front-matter inputs for one compiled flavoured-root artifact. +struct FlavouredRootMeta<'a> { + tree_id: &'a str, + scope: &'a str, + ask: Option<&'a str>, + root_id: Option<&'a str>, + sealed_at: Option, + leaves_folded: usize, + token_estimate: u32, + token_budget: u32, + body: &'a str, +} + +/// Render the compiled artifact: YAML front-matter followed by the clamped body. +fn render_flavoured_root(meta: &FlavouredRootMeta<'_>) -> String { + let mut out = String::new(); + out.push_str("---\n"); + out.push_str("kind: flavoured_root\n"); + out.push_str(&format!("tree_id: {}\n", yaml_quote(meta.tree_id))); + out.push_str(&format!("scope: {}\n", yaml_quote(meta.scope))); + out.push_str(&format!("ask: {}\n", yaml_quote(meta.ask.unwrap_or("")))); + match meta.root_id { + Some(id) => out.push_str(&format!("root_id: {}\n", yaml_quote(id))), + None => out.push_str("root_id: null\n"), + } + match &meta.sealed_at { + Some(ts) => out.push_str(&format!("sealed_at: {}\n", yaml_quote(ts))), + None => out.push_str("sealed_at: null\n"), + } + out.push_str(&format!("leaves_folded: {}\n", meta.leaves_folded)); + out.push_str(&format!("token_estimate: {}\n", meta.token_estimate)); + out.push_str(&format!("token_budget: {}\n", meta.token_budget)); + out.push_str("---\n"); + out.push_str(meta.body); + if !meta.body.is_empty() && !meta.body.ends_with('\n') { + out.push('\n'); + } + out +} + +/// Minimal YAML double-quoted scalar. Collapses interior newlines to spaces +/// (asks are single-instruction strings) and escapes `\` and `"` so the value +/// is always a valid one-line YAML scalar. +fn yaml_quote(value: &str) -> String { + let collapsed: String = value.split_whitespace().collect::>().join(" "); + let escaped = collapsed.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +#[cfg(test)] +#[path = "flavoured_tests.rs"] +mod tests; diff --git a/src/memory/tree/flavoured_tests.rs b/src/memory/tree/flavoured_tests.rs new file mode 100644 index 0000000..a889d3f --- /dev/null +++ b/src/memory/tree/flavoured_tests.rs @@ -0,0 +1,145 @@ +//! Tests for compiled flavoured-tree root artifacts. + +use super::*; +use chrono::{TimeZone, Utc}; +use tempfile::TempDir; + +use crate::memory::chunks::{chunk_id, upsert_chunks, Chunk, Metadata, SourceKind, SourceRef}; +use crate::memory::config::MemoryConfig; +use crate::memory::tree::bucket_seal::LeafRef; +use crate::memory::tree::factory::TreeFactory; +use crate::memory::tree::summarise::ConcatSummariser; + +const ASK: &str = "Distill the author's tweet-writing style: voice, tone, structure."; + +fn test_config() -> (TempDir, MemoryConfig) { + let tmp = TempDir::new().unwrap(); + let cfg = MemoryConfig::new(tmp.path()); + (tmp, cfg) +} + +fn seed_chunk(cfg: &MemoryConfig, seq: u32, content: &str) -> Chunk { + let ts = Utc + .timestamp_millis_opt(1_700_000_000_000 + seq as i64) + .unwrap(); + let chunk = Chunk { + id: chunk_id(SourceKind::Chat, "tweet:alice", seq, content), + content: content.to_string(), + metadata: Metadata { + source_kind: SourceKind::Chat, + source_id: "tweet:alice".into(), + owner: "alice".into(), + timestamp: ts, + time_range: (ts, ts), + tags: vec![], + source_ref: Some(SourceRef::new("tweet://alice/1")), + path_scope: None, + }, + token_count: 40, + seq_in_source: seq, + created_at: ts, + partial_message: false, + }; + upsert_chunks(cfg, std::slice::from_ref(&chunk)).unwrap(); + chunk +} + +async fn seed_and_seal(cfg: &MemoryConfig, factory: &TreeFactory<'_>, n: u32) { + let summariser = ConcatSummariser::new(); + for i in 0..n { + let chunk = seed_chunk(cfg, i, &format!("tweet {i}: shipping is a feature.")); + let leaf = LeafRef { + chunk_id: chunk.id.clone(), + token_count: 1, // under budget — force-sealed below + timestamp: chunk.created_at, + content: chunk.content.clone(), + entities: vec![], + topics: vec![], + score: 0.5, + }; + factory.insert_leaf(cfg, &leaf, &summariser).await.unwrap(); + } + factory.seal_now(cfg, &summariser).await.unwrap(); +} + +#[tokio::test] +async fn compile_root_stages_md_with_ask_and_budget() { + let (_tmp, cfg) = test_config(); + let factory = TreeFactory::flavoured("tweet-style", ASK); + seed_and_seal(&cfg, &factory, 3).await; + + let tree = factory.get_or_create(&cfg).unwrap(); + assert_eq!(tree.kind, TreeKind::Flavoured); + assert_eq!(tree.ask.as_deref(), Some(ASK)); + assert!(tree.root_id.is_some(), "seal must set a root"); + + let md = compile_flavoured_root(&cfg, &tree.id).unwrap(); + + // Front-matter carries the ask and identity. + assert!(md.starts_with("---\n")); + assert!(md.contains("kind: flavoured_root")); + assert!(md.contains(&format!("tree_id: \"{}\"", tree.id))); + assert!(md.contains("scope: \"tweet-style\"")); + assert!(md.contains(&format!("ask: \"{ASK}\""))); + assert!(md.contains("leaves_folded: 3")); + + // The artifact is staged at the stable, fixed path. + let abs = flavoured_root_abs_path(&cfg, "tweet-style"); + assert!(abs.exists(), "compiled root must be written to disk"); + let on_disk = std::fs::read_to_string(&abs).unwrap(); + assert_eq!(on_disk, md); + assert!(abs.ends_with("flavoured/tweet-style.md")); +} + +#[tokio::test] +async fn compile_root_body_is_clamped_to_budget() { + let (_tmp, mut cfg) = test_config(); + cfg.tree.flavour_root_token_budget = 5; // tiny budget forces truncation + let factory = TreeFactory::flavoured("tiny", ASK); + seed_and_seal(&cfg, &factory, 12).await; + + let tree = factory.get_or_create(&cfg).unwrap(); + let md = compile_flavoured_root(&cfg, &tree.id).unwrap(); + + // token_estimate front-matter must respect the budget. + let estimate: u32 = md + .lines() + .find_map(|l| l.strip_prefix("token_estimate: ")) + .and_then(|v| v.parse().ok()) + .expect("token_estimate present"); + assert!(estimate <= 5, "estimate {estimate} exceeds budget"); + assert!(md.contains("token_budget: 5")); +} + +#[tokio::test] +async fn compile_root_rejects_non_flavoured_tree() { + let (_tmp, cfg) = test_config(); + let source = TreeFactory::source("slack:#eng") + .get_or_create(&cfg) + .unwrap(); + let err = compile_flavoured_root(&cfg, &source.id).unwrap_err(); + assert!(err.to_string().contains("not flavoured")); +} + +#[tokio::test] +async fn seal_auto_recompiles_root_artifact() { + // The cascade hook should have written the artifact already, before any + // explicit compile call. + let (_tmp, cfg) = test_config(); + let factory = TreeFactory::flavoured("auto", ASK); + seed_and_seal(&cfg, &factory, 2).await; + + let abs = flavoured_root_abs_path(&cfg, "auto"); + assert!( + abs.exists(), + "seal cascade must auto-compile the flavoured root" + ); +} + +#[test] +fn rel_path_slugifies_scope() { + assert_eq!( + flavoured_root_rel_path("tweet:style/v1"), + "flavoured/tweet-style-v1.md" + ); +} diff --git a/src/memory/tree/mod.rs b/src/memory/tree/mod.rs index 0b94f3b..56bc937 100644 --- a/src/memory/tree/mod.rs +++ b/src/memory/tree/mod.rs @@ -27,6 +27,7 @@ pub mod store; pub mod bucket_seal; mod direct_ingest; pub mod factory; +pub mod flavoured; pub mod flush; mod hydrate; pub mod io; @@ -45,6 +46,7 @@ pub use bucket_seal::{ }; pub use direct_ingest::{ingest_summary, SummaryIngestInput, SummaryIngestOutcome}; pub use factory::{TreeFactory, TreeProfile, GLOBAL_SCOPE}; +pub use flavoured::{compile_flavoured_root, flavoured_root_abs_path, flavoured_root_rel_path}; pub use flush::{ flush_stale_buffers, flush_stale_buffers_default, flush_stale_buffers_with_services, force_flush_tree, @@ -55,7 +57,10 @@ pub use io::{ TreeWriteOutcome, TreeWriteRequest, }; pub use read::read_tree; -pub use registry::{get_or_create_tree, is_unique_violation, new_summary_id, new_tree_id}; +pub use registry::{ + get_or_create_tree, get_or_create_tree_with_ask, is_unique_violation, new_summary_id, + new_tree_id, +}; pub use store::{ Buffer, SummaryNode, Tree, TreeKind, TreeStatus, INPUT_TOKEN_BUDGET, OUTPUT_TOKEN_BUDGET, SUMMARY_FANOUT, diff --git a/tests/flavoured_tree.rs b/tests/flavoured_tree.rs new file mode 100644 index 0000000..4794a46 --- /dev/null +++ b/tests/flavoured_tree.rs @@ -0,0 +1,108 @@ +//! Integration test for ask-driven flavoured trees (issue #68). +//! +//! Seeds N style-evidence leaves into a flavoured tree, seals, and asserts the +//! compiled root artifact exists on disk, stays within the token budget, and +//! carries the ask in its front-matter. + +use chrono::{TimeZone, Utc}; +use tempfile::TempDir; + +use tinycortex::memory::chunks::{chunk_id, upsert_chunks, Chunk, Metadata, SourceKind, SourceRef}; +use tinycortex::memory::config::MemoryConfig; +use tinycortex::memory::tree::bucket_seal::LeafRef; +use tinycortex::memory::tree::factory::TreeFactory; +use tinycortex::memory::tree::flavoured::{compile_flavoured_root, flavoured_root_abs_path}; +use tinycortex::memory::tree::store::TreeKind; +use tinycortex::memory::tree::summarise::ConcatSummariser; + +const ASK: &str = "Distill the author's tweet-writing style: voice, tone, \ + structure, vocabulary, and concrete dos and don'ts."; + +const EVIDENCE: &[&str] = &[ + "ship early, ship often — a demo beats a deck", + "lowercase energy. no exclamation points, ever", + "one idea per tweet. cut the qualifier", + "concrete numbers > adjectives. '3x faster' not 'much faster'", + "end on a hook, not a summary", + "prefer verbs. delete 'really', 'very', 'just'", +]; + +fn seed_leaf(cfg: &MemoryConfig, seq: u32, content: &str) -> LeafRef { + let ts = Utc + .timestamp_millis_opt(1_700_000_000_000 + seq as i64) + .unwrap(); + let chunk = Chunk { + id: chunk_id(SourceKind::Chat, "tweet:alice", seq, content), + content: content.to_string(), + metadata: Metadata { + source_kind: SourceKind::Chat, + source_id: "tweet:alice".into(), + owner: "alice".into(), + timestamp: ts, + time_range: (ts, ts), + tags: vec![], + source_ref: Some(SourceRef::new("tweet://alice")), + path_scope: None, + }, + token_count: 20, + seq_in_source: seq, + created_at: ts, + partial_message: false, + }; + upsert_chunks(cfg, std::slice::from_ref(&chunk)).unwrap(); + LeafRef { + chunk_id: chunk.id, + token_count: 1, // under budget — force-sealed via seal_now + timestamp: ts, + content: content.to_string(), + entities: vec![], + topics: vec![], + score: 0.7, + } +} + +#[tokio::test] +async fn flavoured_tree_compiles_prompt_ready_root() { + let tmp = TempDir::new().unwrap(); + let cfg = MemoryConfig::new(tmp.path()); + let summariser = ConcatSummariser::new(); + + let factory = TreeFactory::flavoured("tweet-style", ASK); + for (i, evidence) in EVIDENCE.iter().enumerate() { + let leaf = seed_leaf(&cfg, i as u32, evidence); + factory.insert_leaf(&cfg, &leaf, &summariser).await.unwrap(); + } + // Force-seal the under-budget L0 buffer (the "compile the profile now" path). + factory.seal_now(&cfg, &summariser).await.unwrap(); + + let tree = factory.get_or_create(&cfg).unwrap(); + assert_eq!(tree.kind, TreeKind::Flavoured); + assert_eq!(tree.ask.as_deref(), Some(ASK)); + assert!(tree.root_id.is_some(), "seal must produce a root"); + + let md = compile_flavoured_root(&cfg, &tree.id).unwrap(); + + // 1. Compiled root exists on the fixed, host-readable path. + let abs = flavoured_root_abs_path(&cfg, "tweet-style"); + assert!(abs.exists()); + assert_eq!(std::fs::read_to_string(&abs).unwrap(), md); + + // 2. Front-matter carries the ask (as a one-line escaped scalar) and identity. + let expected_ask = ASK.split_whitespace().collect::>().join(" "); + assert!(md.contains(&format!("ask: \"{expected_ask}\""))); + assert!(md.contains("kind: flavoured_root")); + assert!(md.contains("scope: \"tweet-style\"")); + assert!(md.contains(&format!("leaves_folded: {}", EVIDENCE.len()))); + + // 3. Body stays within the default 1000-token budget. + let estimate: u32 = md + .lines() + .find_map(|l| l.strip_prefix("token_estimate: ")) + .and_then(|v| v.parse().ok()) + .expect("token_estimate present"); + assert!(estimate <= cfg.tree.flavour_root_token_budget); + + // 4. The evidence actually made it into the profile body. + let body = md.split("---\n").nth(2).unwrap_or_default(); + assert!(body.contains("ship early")); +}