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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions docs/spec/flavoured-trees.md
Original file line number Diff line number Diff line change
@@ -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<String>`:

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
---
<the ≤ budget-token profile body>
```

`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/<scope_slug>.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-<scope_slug>/L<level>/<id>.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.
3 changes: 3 additions & 0 deletions src/memory/chunks/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
3 changes: 2 additions & 1 deletion src/memory/chunks/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/memory/chunks/store_embed_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ fn summary_reembed_tombstone_roundtrips_and_clears() {
status: TreeStatus::Active,
created_at: now,
last_sealed_at: None,
ask: None,
},
)
.unwrap();
Expand Down
17 changes: 17 additions & 0 deletions src/memory/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/memory/retrieval/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/memory/store/content/compose/summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
)
}
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/memory/store/content/paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ pub enum SummaryTreeKind {
Global,
/// Per-topic (entity) tree. Layout: `wiki/summaries/topic-<scope_slug>/L<level>/<id>.md`
Topic,
/// Ask-driven flavoured tree. Layout:
/// `wiki/summaries/flavoured-<scope_slug>/L<level>/<id>.md`.
Flavoured,
}

/// Top-level directory for derived/wiki content (summaries, etc.).
Expand Down Expand Up @@ -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")
}
}
}

Expand Down
1 change: 1 addition & 0 deletions src/memory/sync/rebuild.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions src/memory/tree/bucket_seal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -359,6 +374,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 = ""`.
Expand Down Expand Up @@ -418,6 +434,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(
Expand Down Expand Up @@ -717,6 +734,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,
Expand Down Expand Up @@ -761,6 +779,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(
Expand Down
2 changes: 2 additions & 0 deletions src/memory/tree/bucket_seal_label_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions src/memory/tree/direct_ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh flavoured artifacts after direct ingest

When a flavoured tree is populated through the documented ingest_summary path, this new match arm allows the ingest to stage a flavoured L1 summary, but the function only recompiles the prompt artifact from the cascade hook. For the common under-fanout case (sealed_ids is empty after line 137), the transaction may have just set root_id to this summary, yet flavoured/<scope>.md is never written or refreshed, so hosts reading the stable compiled-root path see a missing or stale profile until enough direct-ingested summaries trigger a higher-level seal.

Useful? React with 👍 / 👎.

};
let staged = stage_summary(
&content_root,
Expand Down
Loading