feat(memory): topic trees (lazy hotness-driven materialisation) (#709) (#799)

Phase 3c of the memory architecture (umbrella #711). Adds per-entity
topic trees spawned lazily when an entity's hotness crosses a threshold,
so Phase 4 retrieval can resolve "what did Alice say about Phoenix?"
without scanning the full chunk pool.

## What's in this commit

New module `src/openhuman/memory/tree/topic_tree/`:

- `types.rs` — `EntityIndexStats` (hotness input), `HotnessCounters` (the
  persisted row) and the `TOPIC_CREATION_THRESHOLD=10.0`,
  `TOPIC_ARCHIVE_THRESHOLD=2.0`, `TOPIC_RECHECK_EVERY=100` constants.
- `hotness.rs` — pure arithmetic scorer plus deterministic
  `hotness_at(entity_id, stats, now_ms)` variant for tests, and a
  piecewise-linear `recency_decay` helper.
- `store.rs` — SQLite helpers (`get`, `get_or_fresh`, `upsert`,
  `distinct_sources_for`, `count`) for the new `mem_tree_entity_hotness`
  table.
- `registry.rs` — `get_or_create_topic_tree`, `force_create_topic_tree`,
  `list_topic_trees`, `archive_topic_tree`. Race-recovers on UNIQUE
  violations via `is_unique_violation`, mirroring `source_tree::registry`.
- `curator.rs` — `maybe_spawn_topic_tree` (per-ingest tick) and
  `force_recompute` (admin path). Implements the
  `TOPIC_RECHECK_EVERY`-gated recompute: refresh `distinct_sources` from
  the entity index, compute hotness, spawn + backfill if threshold
  crossed.
- `backfill.rs` — `backfill_topic_tree` walks the entity index and routes
  every historic leaf (ts-ASC ordered) through
  `source_tree::bucket_seal::append_leaf`. Capped at 500 leaves. Skips
  summary-node hits so topic trees only ever ingest raw leaves. Missing
  chunks log a warn and are skipped, never failing the spawn.
- `routing.rs` — `route_leaf_to_topic_trees` fans a kept leaf out to
  every active matching topic tree and ticks the curator for each
  entity. Archived trees are skipped but counters still bump.

Wired from ingest (`tree/ingest.rs::append_leaves_to_tree`) AFTER the
source-tree `append_leaf` succeeds — non-fatal on error, logged at warn
level so routing issues never poison the ingest hot path.

## Hotness scoring (pure)

```
hotness = ln(mentions + 1)          // dampened volume
        + 0.5 * distinct_sources    // cross-source bonus
        + recency_decay(last_seen)  // 1.0 @ day 0 → 0 @ day 30
        + graph_centrality          // Phase 4+; None → 0
        + 2.0 * query_hits          // retrieval feedback; Phase 4+
```

`graph_centrality` and `query_hits_30d` columns are persisted but the
increment code paths are deferred to later phases, as planned.

## Schema (additive, idempotent)

New table `mem_tree_entity_hotness` (keyed on `entity_id`) added to the
Phase 1 `SCHEMA` constant in `tree/store.rs` so it migrates through the
same `with_connection` path as the existing tables. `CREATE TABLE IF NOT
EXISTS` keeps it re-run-safe. An ancillary index on `last_hotness`
supports future sweep queries.

## Routing

The ingest path is the only non-admin caller. After `append_leaf` puts a
leaf in the source tree, `route_leaf_to_topic_trees` runs with the
chunk's canonical entity list. For each entity:

1. If an active topic tree exists, append the leaf to it (reusing
   `source_tree::bucket_seal::append_leaf` with `entities=[entity_id]`).
2. Tick `maybe_spawn_topic_tree` — may bump counters or, on cadence,
   recompute hotness and spawn + backfill a new tree.

Per-entity errors are caught and logged; a top-level failure is demoted
to a warn in the ingest caller so the source-tree append always wins.

## Reuses from Phase 3a

- `source_tree::bucket_seal::append_leaf` — same `&Tree` API works for
  `TreeKind::Topic` end-to-end.
- `source_tree::summariser::{Summariser, InertSummariser}` — honest stub
  emits empty entity/topic vecs on summary nodes.
- `mem_tree_trees` / `mem_tree_summaries` / `mem_tree_buffers` schema,
  discriminated by `kind = 'topic'` and `scope = <entity canonical id>`.
- Registry race recovery via `is_unique_violation` (catches UNIQUE on
  `insert_tree`, re-queries on collision).
- Idempotent append (duplicate `item_ids` in the L0 buffer are no-ops),
  so backfill is safe to re-run.

## Tests

40 new tests (10 hotness, 3 types, 6 store, 7 registry, 4 curator, 4
backfill, 5 routing, 1 integration). `memory::tree` suite: 201 passing,
0 regressions (Phase 3a baseline was 161 → +40 new).

Coverage highlights:
- Hotness pure math (zero-entity, spike-over-threshold, old-but-widely-
  cited retains signal, query-hit boost, recency decay edges).
- Curator: first-ingest-just-bumps, no-spawn-below-threshold,
  spawn-fires-exactly-once-when-crossed, cadence gating.
- Backfill: appends-all-entity-leaves, skips missing chunks,
  idempotent, skips summary nodes.
- Routing: empty-entities-noop, appends-to-existing-tree, archived-tree-
  skipped, multi-entity-fan-out, end-to-end-integration-materialisation.
- Registry: idempotent get-or-create, UNIQUE race recovery, archive
  flips status (not deletion), kind/scope cleanly separated from source.

## Deliberate non-goals (deferred)

- No JSON-RPC surface (out of scope for Phase 3c core).
- No archive cron sweep — `archive_topic_tree` primitive only.
- `graph_centrality` / `query_hits_30d` increments deferred to later
  phases (columns exist, reads work, writes are Phase 4+).

## Stacked on #789

Base is `feat/709-summary-trees`. Merge #789 first, then rebase this
branch.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-04-22 13:40:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48897af9b3
commit 32b5afd1d9
11 changed files with 1991 additions and 2 deletions
+14 -2
View File
@@ -24,6 +24,7 @@ use crate::openhuman::memory::tree::source_tree::{
append_leaf, get_or_create_source_tree, InertSummariser, LeafRef,
};
use crate::openhuman::memory::tree::store;
use crate::openhuman::memory::tree::topic_tree::route_leaf_to_topic_trees;
use crate::openhuman::memory::tree::types::Chunk;
/// Outcome of one ingest call — extended with per-chunk admission info.
@@ -237,7 +238,7 @@ async fn append_leaves_to_tree(
r.canonical_entities
.iter()
.map(|e| e.canonical_id.clone())
.collect(),
.collect::<Vec<_>>(),
chunk.metadata.tags.clone(),
),
None => (0.0, Vec::new(), chunk.metadata.tags.clone()),
@@ -247,11 +248,22 @@ async fn append_leaves_to_tree(
token_count: chunk.token_count,
timestamp: chunk.metadata.timestamp,
content: chunk.content.clone(),
entities,
entities: entities.clone(),
topics,
score: score_value,
};
append_leaf(config, &tree, &leaf, &summariser).await?;
// Phase 3c (#709): route the leaf to every matching topic tree
// and tick the curator for each entity. Non-fatal on error —
// the source-tree append has already succeeded above.
if let Err(e) = route_leaf_to_topic_trees(config, &leaf, &entities, &summariser).await {
log::warn!(
"[memory_tree::ingest] topic_tree routing failed chunk_id={} err={:#}",
chunk.id,
e
);
}
}
Ok(())
}
+1
View File
@@ -31,6 +31,7 @@ pub mod schemas;
pub mod score;
pub mod source_tree;
pub mod store;
pub mod topic_tree;
pub mod types;
pub use schemas::{
+21
View File
@@ -153,6 +153,27 @@ CREATE TABLE IF NOT EXISTS mem_tree_buffers (
CREATE INDEX IF NOT EXISTS idx_mem_tree_buffers_oldest
ON mem_tree_buffers(oldest_at_ms);
-- Phase 3c (#709): per-entity hotness counters driving lazy topic-tree
-- materialisation. One row per canonical entity_id. Counters are bumped
-- on every ingest; `last_hotness` is recomputed every
-- `TOPIC_RECHECK_EVERY` ingests to decide whether to spawn / archive a
-- topic tree for the entity. TODO: 30-day windowing — for Phase 3c we
-- increment counts forever and rely on project-scale truthfulness.
CREATE TABLE IF NOT EXISTS mem_tree_entity_hotness (
entity_id TEXT PRIMARY KEY,
mention_count_30d INTEGER NOT NULL DEFAULT 0,
distinct_sources INTEGER NOT NULL DEFAULT 0,
last_seen_ms INTEGER,
query_hits_30d INTEGER NOT NULL DEFAULT 0,
graph_centrality REAL,
ingests_since_check INTEGER NOT NULL DEFAULT 0,
last_hotness REAL,
last_updated_ms INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mem_tree_entity_hotness_score
ON mem_tree_entity_hotness(last_hotness);
";
/// Upsert a batch of chunks atomically.
@@ -0,0 +1,304 @@
//! Topic-tree backfill — hydrate a freshly-materialised topic tree with
//! every historical leaf mentioning the entity (#709 Phase 3c).
//!
//! When the curator decides an entity has crossed the hotness threshold
//! for the first time, we create a fresh topic tree AND walk the
//! `mem_tree_entity_index` inverted index to append every prior leaf into
//! its L0 buffer. Reusing `bucket_seal::append_leaf` means the cascade
//! fires automatically — a well-established entity may seal several
//! levels as soon as the tree is spawned.
//!
//! Backfill is intentionally best-effort: missing chunks are skipped with
//! a warn log rather than failing the whole spawn, because Phase 3c is
//! additive — a partial topic tree is still useful.
use anyhow::{Context, Result};
use crate::openhuman::config::Config;
use crate::openhuman::memory::tree::score::store::lookup_entity;
use crate::openhuman::memory::tree::source_tree::bucket_seal::{append_leaf, LeafRef};
use crate::openhuman::memory::tree::source_tree::summariser::Summariser;
use crate::openhuman::memory::tree::source_tree::types::Tree;
use crate::openhuman::memory::tree::store::get_chunk;
/// Max leaves to pull from the entity index during backfill. A hard cap
/// keeps initial spawn latency bounded even for very active entities.
const BACKFILL_LIMIT: usize = 500;
/// Walk the entity index for `entity_id` and append every discovered leaf
/// to `tree`. Returns the number of leaves appended (NOT the number of
/// summaries sealed). Idempotent: `append_leaf` itself is a no-op when a
/// leaf is already in the buffer, so re-running backfill is safe.
pub async fn backfill_topic_tree(
config: &Config,
tree: &Tree,
entity_id: &str,
summariser: &dyn Summariser,
) -> Result<usize> {
log::info!(
"[topic_tree::backfill] start entity_id={} tree_id={}",
entity_id,
tree.id
);
let hits = lookup_entity(config, entity_id, Some(BACKFILL_LIMIT))
.with_context(|| format!("failed to lookup entity {entity_id}"))?;
if hits.is_empty() {
log::debug!(
"[topic_tree::backfill] no entity-index hits for entity_id={} — empty backfill",
entity_id
);
return Ok(0);
}
// Sort by timestamp ASC so the buffer's `oldest_at` and the sealed
// summary's `time_range_start` reflect the true historical order, not
// the DESC ordering `lookup_entity` returns.
let mut hits = hits;
hits.sort_by_key(|h| h.timestamp_ms);
let mut appended = 0usize;
for hit in hits {
// Skip summary-node hits — Phase 3c backfill only routes raw leaves
// into the topic tree. Including summary nodes would fold
// summaries-of-summaries across unrelated sources, which defeats
// the point.
if hit.node_kind != "leaf" {
log::debug!(
"[topic_tree::backfill] skipping non-leaf hit node_id={} kind={}",
hit.node_id,
hit.node_kind
);
continue;
}
let chunk = match get_chunk(config, &hit.node_id)? {
Some(c) => c,
None => {
log::warn!(
"[topic_tree::backfill] missing chunk {} for entity {} — skipping",
hit.node_id,
entity_id
);
continue;
}
};
let leaf = LeafRef {
chunk_id: chunk.id.clone(),
token_count: chunk.token_count,
timestamp: chunk.metadata.timestamp,
content: chunk.content.clone(),
entities: vec![entity_id.to_string()],
topics: chunk.metadata.tags.clone(),
score: hit.score,
};
append_leaf(config, tree, &leaf, summariser)
.await
.with_context(|| {
format!(
"backfill append_leaf failed tree_id={} chunk_id={}",
tree.id, chunk.id
)
})?;
appended += 1;
}
log::info!(
"[topic_tree::backfill] done entity_id={} tree_id={} appended={}",
entity_id,
tree.id,
appended
);
Ok(appended)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory::tree::score::extract::EntityKind;
use crate::openhuman::memory::tree::score::resolver::CanonicalEntity;
use crate::openhuman::memory::tree::score::store::index_entity;
use crate::openhuman::memory::tree::source_tree::store as src_store;
use crate::openhuman::memory::tree::source_tree::summariser::inert::InertSummariser;
use crate::openhuman::memory::tree::store::upsert_chunks;
use crate::openhuman::memory::tree::topic_tree::registry::get_or_create_topic_tree;
use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef};
use chrono::{TimeZone, Utc};
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
(tmp, cfg)
}
fn mk_chunk(source_id: &str, seq: u32, ts_ms: i64, tokens: u32) -> Chunk {
let ts = Utc.timestamp_millis_opt(ts_ms).unwrap();
Chunk {
id: chunk_id(SourceKind::Chat, source_id, seq),
content: format!("substantive chunk mentioning alice {source_id}#{seq}"),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: source_id.to_string(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec!["eng".into()],
source_ref: Some(SourceRef::new(format!("{source_id}://{seq}"))),
},
token_count: tokens,
seq_in_source: seq,
created_at: ts,
}
}
fn sample_entity(canonical: &str, surface: &str) -> CanonicalEntity {
CanonicalEntity {
canonical_id: canonical.to_string(),
kind: EntityKind::Email,
surface: surface.to_string(),
span_start: 0,
span_end: surface.len() as u32,
score: 1.0,
}
}
#[tokio::test]
async fn backfill_appends_all_entity_leaves() {
let (_tmp, cfg) = test_config();
// Persist 3 chunks across 2 sources.
let c1 = mk_chunk("slack:#eng", 0, 1_700_000_000_000, 100);
let c2 = mk_chunk("gmail:alice", 0, 1_700_000_010_000, 100);
let c3 = mk_chunk("slack:#eng", 1, 1_700_000_020_000, 100);
upsert_chunks(&cfg, &[c1.clone(), c2.clone(), c3.clone()]).unwrap();
let e = sample_entity("email:alice@example.com", "alice@example.com");
index_entity(
&cfg,
&e,
&c1.id,
"leaf",
1_700_000_000_000,
Some("source:slack"),
)
.unwrap();
index_entity(
&cfg,
&e,
&c2.id,
"leaf",
1_700_000_010_000,
Some("source:gmail"),
)
.unwrap();
index_entity(
&cfg,
&e,
&c3.id,
"leaf",
1_700_000_020_000,
Some("source:slack"),
)
.unwrap();
let tree = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
let summariser = InertSummariser::new();
let n = backfill_topic_tree(&cfg, &tree, "email:alice@example.com", &summariser)
.await
.unwrap();
assert_eq!(n, 3);
// L0 buffer should hold all three leaves (combined tokens well
// under the 10k seal budget).
let buf = src_store::get_buffer(&cfg, &tree.id, 0).unwrap();
assert_eq!(buf.item_ids.len(), 3);
assert_eq!(buf.token_sum, 300);
// Oldest item is c1.
assert_eq!(buf.oldest_at.unwrap().timestamp_millis(), 1_700_000_000_000);
}
#[tokio::test]
async fn backfill_skips_missing_chunks_without_failing() {
let (_tmp, cfg) = test_config();
let e = sample_entity("email:alice@example.com", "alice@example.com");
// Index a chunk that was never persisted.
index_entity(&cfg, &e, "chunk:missing", "leaf", 1_700_000_000_000, None).unwrap();
// And one that was.
let c = mk_chunk("slack:#eng", 0, 1_700_000_010_000, 100);
upsert_chunks(&cfg, &[c.clone()]).unwrap();
index_entity(
&cfg,
&e,
&c.id,
"leaf",
1_700_000_010_000,
Some("source:slack"),
)
.unwrap();
let tree = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
let summariser = InertSummariser::new();
let n = backfill_topic_tree(&cfg, &tree, "email:alice@example.com", &summariser)
.await
.unwrap();
assert_eq!(n, 1, "only the existing chunk should be appended");
let buf = src_store::get_buffer(&cfg, &tree.id, 0).unwrap();
assert_eq!(buf.item_ids.len(), 1);
}
#[tokio::test]
async fn backfill_is_idempotent() {
let (_tmp, cfg) = test_config();
let c = mk_chunk("slack:#eng", 0, 1_700_000_000_000, 50);
upsert_chunks(&cfg, &[c.clone()]).unwrap();
let e = sample_entity("email:alice@example.com", "alice@example.com");
index_entity(
&cfg,
&e,
&c.id,
"leaf",
1_700_000_000_000,
Some("source:slack"),
)
.unwrap();
let tree = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
let summariser = InertSummariser::new();
backfill_topic_tree(&cfg, &tree, "email:alice@example.com", &summariser)
.await
.unwrap();
backfill_topic_tree(&cfg, &tree, "email:alice@example.com", &summariser)
.await
.unwrap();
// append_leaf is idempotent so the buffer still has exactly one row.
let buf = src_store::get_buffer(&cfg, &tree.id, 0).unwrap();
assert_eq!(buf.item_ids.len(), 1);
}
#[tokio::test]
async fn backfill_skips_summary_nodes() {
let (_tmp, cfg) = test_config();
let e = sample_entity("email:alice@example.com", "alice@example.com");
// A summary-node hit in the entity index — should be skipped.
index_entity(
&cfg,
&e,
"summary:L1:abc",
"summary",
1_700_000_000_000,
Some("source:slack"),
)
.unwrap();
let tree = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
let summariser = InertSummariser::new();
let n = backfill_topic_tree(&cfg, &tree, "email:alice@example.com", &summariser)
.await
.unwrap();
assert_eq!(n, 0);
}
}
@@ -0,0 +1,370 @@
//! Topic-tree curator — the hotness gate (#709 Phase 3c).
//!
//! On every ingest that touches an entity we bump cheap counters
//! (`mention_count_30d`, `last_seen_ms`, `ingests_since_check`). Every
//! [`TOPIC_RECHECK_EVERY`] bumps we run the full hotness recompute:
//!
//! 1. Refresh `distinct_sources` from `mem_tree_entity_index`.
//! 2. Compute [`hotness`](super::hotness::hotness).
//! 3. If hotness ≥ [`TOPIC_CREATION_THRESHOLD`] and no topic tree exists
//! yet → create one and kick off [`backfill_topic_tree`].
//! 4. Reset `ingests_since_check` to 0.
//!
//! The function is idempotent: if a topic tree already exists for the
//! entity it's a no-op at the creation step. Spawning is single-shot —
//! re-crossing the threshold after an archive would require explicit
//! unarchival (not Phase 3c).
use anyhow::Result;
use chrono::Utc;
use crate::openhuman::config::Config;
use crate::openhuman::memory::tree::source_tree::store as src_store;
use crate::openhuman::memory::tree::source_tree::summariser::Summariser;
use crate::openhuman::memory::tree::source_tree::types::{Tree, TreeKind};
use crate::openhuman::memory::tree::topic_tree::backfill::backfill_topic_tree;
use crate::openhuman::memory::tree::topic_tree::hotness::hotness_at;
use crate::openhuman::memory::tree::topic_tree::registry::get_or_create_topic_tree;
use crate::openhuman::memory::tree::topic_tree::store::{
distinct_sources_for, get_or_fresh, upsert,
};
use crate::openhuman::memory::tree::topic_tree::types::{
HotnessCounters, TOPIC_CREATION_THRESHOLD, TOPIC_RECHECK_EVERY,
};
/// Outcome of one curator invocation. Surfaced so the caller (typically
/// the routing layer) can log / emit metrics.
#[derive(Clone, Debug, PartialEq)]
pub enum SpawnOutcome {
/// Counters bumped; hotness not yet recomputed this round.
CountersBumped,
/// Full recompute ran; hotness below threshold, no tree spawned.
BelowThreshold { hotness: f32 },
/// Tree already existed — just bumped counters and refreshed hotness.
TreeExists { hotness: f32, tree_id: String },
/// Brand new topic tree materialised.
Spawned {
hotness: f32,
tree_id: String,
backfilled: usize,
},
}
/// Record an ingest touching `entity_id` and, when the recheck cadence
/// fires, consider spawning a topic tree.
///
/// `summariser` is used only when a spawn + backfill happens; passing an
/// [`InertSummariser`](crate::openhuman::memory::tree::source_tree::summariser::inert::InertSummariser)
/// is fine for Phase 3c.
pub async fn maybe_spawn_topic_tree(
config: &Config,
entity_id: &str,
summariser: &dyn Summariser,
) -> Result<SpawnOutcome> {
let now_ms = Utc::now().timestamp_millis();
// 1. Read existing counters (fresh row if first sighting).
let mut counters = get_or_fresh(config, entity_id)?;
// 2. Cheap per-ingest bumps.
counters.mention_count_30d = counters.mention_count_30d.saturating_add(1);
counters.last_seen_ms = Some(now_ms);
counters.ingests_since_check = counters.ingests_since_check.saturating_add(1);
counters.last_updated_ms = now_ms;
// 3. Decide whether to run the full recompute.
if counters.ingests_since_check < TOPIC_RECHECK_EVERY {
upsert(config, &counters)?;
log::debug!(
"[topic_tree::curator] bumped counters entity={} mentions={} ingests_since_check={}",
entity_id,
counters.mention_count_30d,
counters.ingests_since_check
);
return Ok(SpawnOutcome::CountersBumped);
}
// 4. Full recompute.
run_full_recompute(config, entity_id, &mut counters, now_ms, summariser).await
}
/// Admin path: force a recompute + spawn-if-hot regardless of the
/// [`TOPIC_RECHECK_EVERY`] cadence. Used by (future) RPCs that want to
/// prod the curator without waiting for the next bump cycle.
pub async fn force_recompute(
config: &Config,
entity_id: &str,
summariser: &dyn Summariser,
) -> Result<SpawnOutcome> {
let now_ms = Utc::now().timestamp_millis();
let mut counters = get_or_fresh(config, entity_id)?;
counters.last_updated_ms = now_ms;
run_full_recompute(config, entity_id, &mut counters, now_ms, summariser).await
}
async fn run_full_recompute(
config: &Config,
entity_id: &str,
counters: &mut HotnessCounters,
now_ms: i64,
summariser: &dyn Summariser,
) -> Result<SpawnOutcome> {
// Refresh distinct_sources from the entity index — the authoritative
// source of cross-tree coverage.
let distinct = distinct_sources_for(config, entity_id)?;
counters.distinct_sources = distinct;
// Compute hotness against the refreshed stats.
let stats = counters.stats();
let h = hotness_at(entity_id, &stats, now_ms);
counters.last_hotness = Some(h);
counters.ingests_since_check = 0;
let outcome = if h < TOPIC_CREATION_THRESHOLD {
log::debug!(
"[topic_tree::curator] below threshold entity={} hotness={:.3} threshold={}",
entity_id,
h,
TOPIC_CREATION_THRESHOLD
);
SpawnOutcome::BelowThreshold { hotness: h }
} else if let Some(existing) = existing_topic_tree(config, entity_id)? {
log::debug!(
"[topic_tree::curator] tree already exists entity={} tree_id={} hotness={:.3}",
entity_id,
existing.id,
h
);
SpawnOutcome::TreeExists {
hotness: h,
tree_id: existing.id,
}
} else {
// Crossed threshold for the first time — materialise.
log::info!(
"[topic_tree::curator] spawning topic tree entity={} hotness={:.3}",
entity_id,
h
);
let tree = get_or_create_topic_tree(config, entity_id)?;
let backfilled = backfill_topic_tree(config, &tree, entity_id, summariser).await?;
SpawnOutcome::Spawned {
hotness: h,
tree_id: tree.id,
backfilled,
}
};
// Persist the refreshed counters regardless of outcome.
upsert(config, counters)?;
Ok(outcome)
}
fn existing_topic_tree(config: &Config, entity_id: &str) -> Result<Option<Tree>> {
src_store::get_tree_by_scope(config, TreeKind::Topic, entity_id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory::tree::score::extract::EntityKind;
use crate::openhuman::memory::tree::score::resolver::CanonicalEntity;
use crate::openhuman::memory::tree::score::store::index_entity;
use crate::openhuman::memory::tree::source_tree::summariser::inert::InertSummariser;
use crate::openhuman::memory::tree::store::upsert_chunks;
use crate::openhuman::memory::tree::topic_tree::store::get;
use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef};
use chrono::{TimeZone, Utc};
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
(tmp, cfg)
}
fn seed_leaf_for_entity(cfg: &Config, entity_id: &str, source_tree: &str, seq: u32) {
let ts_ms = 1_700_000_000_000 + (seq as i64) * 1_000;
let ts = Utc.timestamp_millis_opt(ts_ms).unwrap();
let c = Chunk {
id: chunk_id(SourceKind::Chat, source_tree, seq),
content: format!("mentioning entity in {source_tree}#{seq}"),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: source_tree.to_string(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec![],
source_ref: Some(SourceRef::new(format!("{source_tree}://{seq}"))),
},
token_count: 50,
seq_in_source: seq,
created_at: ts,
};
upsert_chunks(cfg, &[c.clone()]).unwrap();
let e = CanonicalEntity {
canonical_id: entity_id.to_string(),
kind: EntityKind::Email,
surface: entity_id.to_string(),
span_start: 0,
span_end: entity_id.len() as u32,
score: 1.0,
};
index_entity(cfg, &e, &c.id, "leaf", ts_ms, Some(source_tree)).unwrap();
}
#[tokio::test]
async fn first_ingest_just_bumps_counters() {
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
let out = maybe_spawn_topic_tree(&cfg, "email:alice@example.com", &summariser)
.await
.unwrap();
assert_eq!(out, SpawnOutcome::CountersBumped);
let c = get(&cfg, "email:alice@example.com").unwrap().unwrap();
assert_eq!(c.mention_count_30d, 1);
assert_eq!(c.ingests_since_check, 1);
assert!(c.last_hotness.is_none(), "no recompute yet");
}
#[tokio::test]
async fn no_spawn_below_threshold_on_recompute() {
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
// Force a recompute on the very first call — but with no index data
// the hotness comes out well below threshold.
let out = force_recompute(&cfg, "email:alice@example.com", &summariser)
.await
.unwrap();
match out {
SpawnOutcome::BelowThreshold { hotness } => {
assert!(hotness < TOPIC_CREATION_THRESHOLD);
}
other => panic!("expected BelowThreshold, got {other:?}"),
}
// No topic tree created.
let t = existing_topic_tree(&cfg, "email:alice@example.com").unwrap();
assert!(t.is_none());
}
#[tokio::test]
async fn spawn_fires_exactly_once_when_threshold_crossed() {
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
// Seed substantial activity across several sources so hotness is
// well above threshold.
let mut counters = HotnessCounters::fresh("email:alice@example.com", 0);
counters.mention_count_30d = 500;
counters.distinct_sources = 4;
counters.last_seen_ms = Some(Utc::now().timestamp_millis());
counters.query_hits_30d = 5;
upsert(&cfg, &counters).unwrap();
// Seed leaves in the entity index so backfill has something to do.
for i in 0..3 {
seed_leaf_for_entity(&cfg, "email:alice@example.com", "slack:#eng", i);
}
for i in 0..2 {
seed_leaf_for_entity(&cfg, "email:alice@example.com", "gmail:alice", i);
}
let out = force_recompute(&cfg, "email:alice@example.com", &summariser)
.await
.unwrap();
match out {
SpawnOutcome::Spawned {
hotness,
tree_id,
backfilled,
} => {
assert!(hotness >= TOPIC_CREATION_THRESHOLD);
assert!(tree_id.starts_with("topic:"));
assert_eq!(backfilled, 5);
}
other => panic!("expected Spawned, got {other:?}"),
}
// Re-running should report TreeExists, NOT a second spawn.
let out2 = force_recompute(&cfg, "email:alice@example.com", &summariser)
.await
.unwrap();
match out2 {
SpawnOutcome::TreeExists { tree_id, .. } => {
assert!(tree_id.starts_with("topic:"));
}
other => panic!("expected TreeExists on retry, got {other:?}"),
}
}
#[tokio::test]
async fn recompute_refreshes_distinct_sources_from_entity_index() {
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
// Counter says 0 distinct sources but the index has 3.
let mut counters = HotnessCounters::fresh("email:alice@example.com", 0);
counters.mention_count_30d = 1;
counters.distinct_sources = 0;
upsert(&cfg, &counters).unwrap();
seed_leaf_for_entity(&cfg, "email:alice@example.com", "slack:#eng", 0);
seed_leaf_for_entity(&cfg, "email:alice@example.com", "gmail:alice", 0);
seed_leaf_for_entity(&cfg, "email:alice@example.com", "notion:abc", 0);
force_recompute(&cfg, "email:alice@example.com", &summariser)
.await
.unwrap();
let c = get(&cfg, "email:alice@example.com").unwrap().unwrap();
assert_eq!(c.distinct_sources, 3);
// ingests_since_check should also reset.
assert_eq!(c.ingests_since_check, 0);
}
#[tokio::test]
async fn cadence_only_recomputes_every_n_ingests() {
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
// Pre-seed entity index with enough cross-source signal that the
// recompute (which refreshes `distinct_sources` from the index) will
// still produce a hotness above threshold.
for i in 0..4 {
seed_leaf_for_entity(
&cfg,
"email:alice@example.com",
&format!("slack:#eng-{i}"),
i,
);
}
let mut counters = HotnessCounters::fresh("email:alice@example.com", 0);
counters.mention_count_30d = 500;
counters.distinct_sources = 4;
counters.last_seen_ms = Some(Utc::now().timestamp_millis());
// Boost query_hits so hotness stays comfortably above threshold
// after the distinct_sources refresh.
counters.query_hits_30d = 5;
// ingests_since_check just below the cadence: next call should
// NOT yet recompute.
counters.ingests_since_check = TOPIC_RECHECK_EVERY - 2;
upsert(&cfg, &counters).unwrap();
let out = maybe_spawn_topic_tree(&cfg, "email:alice@example.com", &summariser)
.await
.unwrap();
assert_eq!(out, SpawnOutcome::CountersBumped);
// No tree yet — cadence not crossed.
assert!(existing_topic_tree(&cfg, "email:alice@example.com")
.unwrap()
.is_none());
// One more bump — now ingests_since_check == TOPIC_RECHECK_EVERY
// and the recompute fires.
let out2 = maybe_spawn_topic_tree(&cfg, "email:alice@example.com", &summariser)
.await
.unwrap();
match out2 {
SpawnOutcome::Spawned { .. } | SpawnOutcome::TreeExists { .. } => {}
other => panic!("expected Spawn/TreeExists after cadence, got {other:?}"),
}
}
}
@@ -0,0 +1,203 @@
//! Pure hotness math for Phase 3c (#709).
//!
//! The formula intentionally folds a handful of pre-existing signals into
//! one arithmetic score. No LLM, no learned weights — the goal is
//! deterministic, greppable, testable behaviour:
//!
//! ```text
//! hotness = ln(mentions + 1) // dampened high-volume bias
//! + 0.5 * distinct_sources // cross-source is valuable
//! + recency_decay(last_seen) // prefer active entities
//! + graph_centrality // Phase 4+ (None → 0.0)
//! + 2.0 * query_hits // retrieval feedback (Phase 4+)
//! ```
//!
//! Recency decay is a piecewise linear taper:
//! - age ≤ 1 day → 1.0
//! - age 1…7 days → 1.0 → 0.5
//! - age 7…30 days → 0.5 → 0.0
//! - age > 30 days → 0.0
//!
//! The unit tests lock in the coarse behaviour (zero-mention, spike,
//! old-but-widely-cited) so tuning the constants later stays honest.
use chrono::Utc;
use crate::openhuman::memory::tree::topic_tree::types::EntityIndexStats;
/// Pure hotness function — no I/O, no clocks unless the caller passes one.
///
/// `entity_id` is taken for diagnostic logging only and has no effect on
/// the numeric result.
pub fn hotness(entity_id: &str, idx: &EntityIndexStats) -> f32 {
let now_ms = Utc::now().timestamp_millis();
hotness_at(entity_id, idx, now_ms)
}
/// Deterministic variant — computes hotness as if the current wall clock
/// were `now_ms`. Useful in tests so the recency term doesn't drift.
pub fn hotness_at(entity_id: &str, idx: &EntityIndexStats, now_ms: i64) -> f32 {
let mention_weight = ((idx.mention_count_30d as f32) + 1.0).ln();
let source_weight = (idx.distinct_sources as f32) * 0.5;
let recency_weight = recency_decay(idx.last_seen_ms, now_ms);
let centrality = idx.graph_centrality.unwrap_or(0.0);
let query_weight = (idx.query_hits_30d as f32) * 2.0;
let total = mention_weight + source_weight + recency_weight + centrality + query_weight;
log::debug!(
"[topic_tree::hotness] id={} mentions={} sources={} recency={:.3} centrality={:.3} \
queries={} total={:.3}",
entity_id,
idx.mention_count_30d,
idx.distinct_sources,
recency_weight,
centrality,
idx.query_hits_30d,
total
);
total
}
/// Recency decay helper. Operates on absolute epoch-millis so tests can
/// pin the clock. Returns 0.0 when `last_seen_ms` is `None`.
pub fn recency_decay(last_seen_ms: Option<i64>, now_ms: i64) -> f32 {
let Some(last_seen) = last_seen_ms else {
return 0.0;
};
let age_ms = (now_ms - last_seen).max(0);
const DAY_MS: i64 = 24 * 60 * 60 * 1_000;
let age_days = (age_ms as f32) / (DAY_MS as f32);
if age_days <= 1.0 {
1.0
} else if age_days <= 7.0 {
// 1.0 at day 1, 0.5 at day 7
let frac = (age_days - 1.0) / 6.0;
1.0 - 0.5 * frac
} else if age_days <= 30.0 {
// 0.5 at day 7, 0.0 at day 30
let frac = (age_days - 7.0) / 23.0;
0.5 - 0.5 * frac
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
const DAY_MS: i64 = 24 * 60 * 60 * 1_000;
fn stats(mentions: u32, sources: u32, last_seen: Option<i64>) -> EntityIndexStats {
EntityIndexStats {
mention_count_30d: mentions,
distinct_sources: sources,
last_seen_ms: last_seen,
query_hits_30d: 0,
graph_centrality: None,
}
}
#[test]
fn zero_signal_entity_is_zero() {
let now_ms = 1_700_000_000_000;
let s = stats(0, 0, None);
let h = hotness_at("e:none", &s, now_ms);
// ln(0+1) + 0 + 0 + 0 + 0 = 0
assert!(h.abs() < 1e-6);
}
#[test]
fn spike_of_mentions_pushes_over_creation_threshold() {
use crate::openhuman::memory::tree::topic_tree::types::TOPIC_CREATION_THRESHOLD;
let now_ms = 1_700_000_000_000;
// 100 mentions across 5 sources, 3 recent query hits, seen today.
let s = EntityIndexStats {
mention_count_30d: 100,
distinct_sources: 5,
last_seen_ms: Some(now_ms - DAY_MS / 2),
query_hits_30d: 3,
graph_centrality: None,
};
let h = hotness_at("e:hot", &s, now_ms);
assert!(
h > TOPIC_CREATION_THRESHOLD,
"expected hot entity > {TOPIC_CREATION_THRESHOLD}, got {h}"
);
}
#[test]
fn old_but_widely_cited_still_has_some_heat() {
// 50 mentions, 8 sources, last seen 20 days ago, no queries.
let now_ms = 1_700_000_000_000;
let s = EntityIndexStats {
mention_count_30d: 50,
distinct_sources: 8,
last_seen_ms: Some(now_ms - 20 * DAY_MS),
query_hits_30d: 0,
graph_centrality: None,
};
let h = hotness_at("e:old-wide", &s, now_ms);
// mention_weight = ln(51) ≈ 3.93, source_weight = 4.0,
// recency at day 20 ≈ 0.5 * (30-20)/23 ≈ 0.217 → total ≈ 8.1
assert!(h > 5.0, "widely-cited entity should retain signal: {h}");
}
#[test]
fn ancient_single_mention_decays_toward_zero() {
let now_ms = 1_700_000_000_000;
let s = stats(1, 1, Some(now_ms - 60 * DAY_MS));
let h = hotness_at("e:ancient", &s, now_ms);
// ln(2) + 0.5 + 0 = ~1.19 — well below creation threshold
assert!(h < 2.0, "ancient entity should decay: {h}");
}
#[test]
fn recency_decay_today_is_one() {
let now_ms = 1_700_000_000_000;
let r = recency_decay(Some(now_ms), now_ms);
assert!((r - 1.0).abs() < 1e-6);
}
#[test]
fn recency_decay_week_old_is_half() {
let now_ms = 1_700_000_000_000;
let r = recency_decay(Some(now_ms - 7 * DAY_MS), now_ms);
assert!((r - 0.5).abs() < 1e-3, "expected 0.5 at 7d, got {r}");
}
#[test]
fn recency_decay_month_old_is_zero() {
let now_ms = 1_700_000_000_000;
let r = recency_decay(Some(now_ms - 30 * DAY_MS), now_ms);
assert!(r.abs() < 1e-3, "expected ~0 at 30d, got {r}");
}
#[test]
fn recency_decay_none_last_seen_is_zero() {
assert_eq!(recency_decay(None, 1_700_000_000_000), 0.0);
}
#[test]
fn query_hits_boost_hotness_aggressively() {
let now_ms = 1_700_000_000_000;
let base = stats(5, 1, Some(now_ms));
let boosted = EntityIndexStats {
query_hits_30d: 10,
..base.clone()
};
let h_base = hotness_at("e", &base, now_ms);
let h_boosted = hotness_at("e", &boosted, now_ms);
// 10 query hits * 2.0 = +20
assert!(h_boosted - h_base > 19.0);
}
#[test]
fn future_last_seen_is_treated_as_now() {
// Clock drift could produce negative ages — we clamp at 0.
let now_ms = 1_700_000_000_000;
let r = recency_decay(Some(now_ms + DAY_MS), now_ms);
assert!((r - 1.0).abs() < 1e-6);
}
}
@@ -0,0 +1,46 @@
//! Phase 3c — topic trees (lazy materialisation) (#709).
//!
//! A *topic tree* is a per-entity summary tree whose leaves are all
//! chunks mentioning that entity, regardless of the source they came
//! from. Topic trees are spawned lazily — only when an entity's hotness
//! crosses a threshold — and then receive new leaves via the ingest path
//! alongside the per-source tree. See `docs/MEMORY_ARCHITECTURE_LLD.md`
//! for the full design.
//!
//! Phase 3c surface:
//! - [`curator::maybe_spawn_topic_tree`] — per-ingest tick; bumps
//! counters and spawns a topic tree when hotness crosses
//! [`types::TOPIC_CREATION_THRESHOLD`].
//! - [`routing::route_leaf_to_topic_trees`] — called by the ingest path
//! after the source-tree append; fans a kept leaf out to every
//! matching entity's topic tree.
//! - [`registry::get_or_create_topic_tree`] /
//! [`registry::archive_topic_tree`] — primitives for admin flows.
//! - [`backfill::backfill_topic_tree`] — walk the entity index and
//! hydrate a freshly spawned tree with historic leaves.
//! - [`hotness::hotness`] — pure arithmetic over pre-existing signals;
//! easy to unit-test.
//!
//! Tree mechanics (buffer, seal, cascade) are **not reimplemented** here
//! — `append_leaf` from [`super::source_tree::bucket_seal`] takes a
//! `&Tree` so it works for any `TreeKind`. The Phase 3c code only adds
//! the hotness layer and the per-entity fan-out.
pub mod backfill;
pub mod curator;
pub mod hotness;
pub mod registry;
pub mod routing;
pub mod store;
pub mod types;
pub use curator::{maybe_spawn_topic_tree, SpawnOutcome};
pub use hotness::{hotness, recency_decay};
pub use registry::{
archive_topic_tree, force_create_topic_tree, get_or_create_topic_tree, list_topic_trees,
};
pub use routing::route_leaf_to_topic_trees;
pub use types::{
EntityIndexStats, HotnessCounters, TOPIC_ARCHIVE_THRESHOLD, TOPIC_CREATION_THRESHOLD,
TOPIC_RECHECK_EVERY,
};
@@ -0,0 +1,289 @@
//! Topic tree registry — get-or-create / archive (#709 Phase 3c).
//!
//! Topic trees share the same `mem_tree_trees` schema as source trees; the
//! only difference is `kind = 'topic'` and `scope = <entity canonical id>`.
//! Callers should NOT reach into this module to create topic trees
//! eagerly — use the curator ([`super::curator::maybe_spawn_topic_tree`])
//! so creation is gated on hotness. Admin flows (future RPC) that want to
//! bypass the gate can call [`force_create_topic_tree`] directly.
use anyhow::{Context, Result};
use chrono::Utc;
use uuid::Uuid;
use crate::openhuman::config::Config;
use crate::openhuman::memory::tree::source_tree::store;
use crate::openhuman::memory::tree::source_tree::types::{Tree, TreeKind, TreeStatus};
/// Look up the topic tree for `entity_id`, or create a new one.
///
/// The `entity_id` is a canonical id from the entity resolver (e.g.
/// `"email:alice@example.com"` or `"hashtag:launch"`). Scope uses the
/// canonical id verbatim so re-lookups are stable.
pub fn get_or_create_topic_tree(config: &Config, entity_id: &str) -> Result<Tree> {
if let Some(existing) = store::get_tree_by_scope(config, TreeKind::Topic, entity_id)? {
log::debug!(
"[topic_tree::registry] found tree id={} entity={}",
existing.id,
entity_id
);
return Ok(existing);
}
create_new(config, entity_id)
}
/// Public alias used by the admin "force materialise" path — semantically
/// identical to [`get_or_create_topic_tree`] but named to make intent at
/// the call site obvious.
pub fn force_create_topic_tree(config: &Config, entity_id: &str) -> Result<Tree> {
get_or_create_topic_tree(config, entity_id)
}
/// List all topic trees (both active and archived). Ordered by creation time
/// ascending for stable output.
pub fn list_topic_trees(config: &Config) -> Result<Vec<Tree>> {
use rusqlite::params;
crate::openhuman::memory::tree::store::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
FROM mem_tree_trees
WHERE kind = ?1
ORDER BY created_at_ms ASC",
)?;
let rows = stmt
.query_map(params![TreeKind::Topic.as_str()], row_to_tree_loose)?
.collect::<rusqlite::Result<Vec<_>>>()
.context("failed to list topic trees")?;
Ok(rows)
})
}
/// Flip a topic tree's status to `archived`. Existing rows remain queryable;
/// new leaves will NOT be routed to this tree until it's manually unarchived
/// (unarchive is not a Phase 3c primitive — Phase 3c just stops routing).
pub fn archive_topic_tree(config: &Config, tree_id: &str) -> Result<()> {
use rusqlite::params;
crate::openhuman::memory::tree::store::with_connection(config, |conn| {
let n = conn
.execute(
"UPDATE mem_tree_trees
SET status = ?1
WHERE id = ?2 AND kind = ?3",
params![
TreeStatus::Archived.as_str(),
tree_id,
TreeKind::Topic.as_str()
],
)
.with_context(|| format!("failed to archive topic tree {tree_id}"))?;
if n == 0 {
log::warn!(
"[topic_tree::registry] archive_topic_tree: no topic tree with id={tree_id}"
);
} else {
log::info!("[topic_tree::registry] archived topic tree id={tree_id}");
}
Ok(())
})
}
fn create_new(config: &Config, entity_id: &str) -> Result<Tree> {
let tree = Tree {
id: new_topic_tree_id(),
kind: TreeKind::Topic,
scope: entity_id.to_string(),
root_id: None,
max_level: 0,
status: TreeStatus::Active,
created_at: Utc::now(),
last_sealed_at: None,
};
match store::insert_tree(config, &tree) {
Ok(()) => {
log::info!(
"[topic_tree::registry] created topic tree id={} entity={}",
tree.id,
entity_id
);
Ok(tree)
}
Err(err) if is_unique_violation(&err) => {
log::debug!(
"[topic_tree::registry] UNIQUE race for entity={} — re-querying",
entity_id
);
store::get_tree_by_scope(config, TreeKind::Topic, entity_id)?.ok_or_else(|| {
anyhow::anyhow!(
"UNIQUE violation on insert but no row found on re-query for entity {entity_id}"
)
})
}
Err(err) => Err(err),
}
}
fn is_unique_violation(err: &anyhow::Error) -> bool {
if let Some(rusqlite_err) = err.downcast_ref::<rusqlite::Error>() {
if let rusqlite::Error::SqliteFailure(sqlite_err, _) = rusqlite_err {
return sqlite_err.code == rusqlite::ErrorCode::ConstraintViolation;
}
}
let msg = format!("{err:#}");
msg.contains("UNIQUE constraint failed")
}
fn new_topic_tree_id() -> String {
format!("{}:{}", TreeKind::Topic.as_str(), Uuid::new_v4())
}
/// Row mapper — duplicated from `source_tree::store::row_to_tree` because
/// that one is private. Kept intentionally loose: topic-tree listing is
/// not a hot path so the string parsing cost is immaterial.
fn row_to_tree_loose(row: &rusqlite::Row<'_>) -> rusqlite::Result<Tree> {
use chrono::TimeZone;
let id: String = row.get(0)?;
let kind_s: String = row.get(1)?;
let scope: String = row.get(2)?;
let root_id: Option<String> = row.get(3)?;
let max_level: i64 = row.get(4)?;
let status_s: String = row.get(5)?;
let created_ms: i64 = row.get(6)?;
let last_sealed_ms: Option<i64> = row.get(7)?;
let kind = TreeKind::parse(&kind_s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, e.into())
})?;
let status = TreeStatus::parse(&status_s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(5, rusqlite::types::Type::Text, e.into())
})?;
let created_at = Utc
.timestamp_millis_opt(created_ms)
.single()
.ok_or_else(|| {
rusqlite::Error::FromSqlConversionFailure(
6,
rusqlite::types::Type::Integer,
format!("invalid created_at_ms {created_ms}").into(),
)
})?;
let last_sealed_at = last_sealed_ms
.map(|ms| {
Utc.timestamp_millis_opt(ms).single().ok_or_else(|| {
rusqlite::Error::FromSqlConversionFailure(
7,
rusqlite::types::Type::Integer,
format!("invalid last_sealed_at_ms {ms}").into(),
)
})
})
.transpose()?;
Ok(Tree {
id,
kind,
scope,
root_id,
max_level: max_level.max(0) as u32,
status,
created_at,
last_sealed_at,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
(tmp, cfg)
}
#[test]
fn get_or_create_is_idempotent_on_entity_id() {
let (_tmp, cfg) = test_config();
let first = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
let second = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
assert_eq!(first.id, second.id);
assert_eq!(first.kind, TreeKind::Topic);
assert_eq!(first.status, TreeStatus::Active);
assert_eq!(first.scope, "email:alice@example.com");
}
#[test]
fn different_entities_yield_different_trees() {
let (_tmp, cfg) = test_config();
let a = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
let b = get_or_create_topic_tree(&cfg, "email:bob@example.com").unwrap();
assert_ne!(a.id, b.id);
assert_ne!(a.scope, b.scope);
}
#[test]
fn topic_tree_and_source_tree_share_scope_space_cleanly() {
// A source tree and a topic tree can have the same *logical*
// scope string (e.g. an entity id that looks like a source id) —
// the UNIQUE constraint is on (kind, scope), not scope alone.
let (_tmp, cfg) = test_config();
let source =
crate::openhuman::memory::tree::source_tree::registry::get_or_create_source_tree(
&cfg,
"shared:slack:#eng",
)
.unwrap();
let topic = get_or_create_topic_tree(&cfg, "shared:slack:#eng").unwrap();
assert_ne!(source.id, topic.id);
assert_eq!(source.kind, TreeKind::Source);
assert_eq!(topic.kind, TreeKind::Topic);
}
#[test]
fn topic_tree_id_has_expected_prefix() {
let id = new_topic_tree_id();
assert!(id.starts_with("topic:"));
}
#[test]
fn archive_flips_status_and_keeps_rows_readable() {
let (_tmp, cfg) = test_config();
let t = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
archive_topic_tree(&cfg, &t.id).unwrap();
let refetched = store::get_tree(&cfg, &t.id).unwrap().unwrap();
assert_eq!(refetched.status, TreeStatus::Archived);
// get_or_create should still return the same (archived) row rather
// than creating a new one — archiving is NOT deletion.
let again = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
assert_eq!(again.id, t.id);
assert_eq!(again.status, TreeStatus::Archived);
}
#[test]
fn archive_is_noop_on_nonexistent() {
let (_tmp, cfg) = test_config();
// Shouldn't error — just log a warning.
archive_topic_tree(&cfg, "topic:does-not-exist").unwrap();
}
#[test]
fn list_topic_trees_returns_only_topics() {
let (_tmp, cfg) = test_config();
// Mix of source + topic trees.
crate::openhuman::memory::tree::source_tree::registry::get_or_create_source_tree(
&cfg,
"slack:#eng",
)
.unwrap();
get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
get_or_create_topic_tree(&cfg, "email:bob@example.com").unwrap();
let topics = list_topic_trees(&cfg).unwrap();
assert_eq!(topics.len(), 2);
for t in &topics {
assert_eq!(t.kind, TreeKind::Topic);
}
}
}
@@ -0,0 +1,348 @@
//! Per-leaf routing into topic trees (#709 Phase 3c).
//!
//! This is the hook point the ingest path calls after it has finished
//! appending a leaf to its source tree. For each canonical entity on the
//! chunk we:
//!
//! 1. Append the leaf to that entity's topic tree *if* one already exists
//! (active status only — archived topic trees don't receive new
//! leaves).
//! 2. Notify the curator that this entity was just mentioned, which may
//! cross the hotness threshold and spawn a new topic tree.
//!
//! Steps 1 and 2 are independent — if an entity's topic tree already
//! exists, step 2 just bumps counters; if it doesn't, step 1 is skipped
//! and step 2 may materialise it on this ingest.
//!
//! Failures are logged at warn level but never bubble up: Phase 3c is
//! additive and must not poison the ingest path. The source-tree append
//! has already succeeded by the time we get here.
use anyhow::Result;
use crate::openhuman::config::Config;
use crate::openhuman::memory::tree::source_tree::bucket_seal::{append_leaf, LeafRef};
use crate::openhuman::memory::tree::source_tree::store as src_store;
use crate::openhuman::memory::tree::source_tree::summariser::Summariser;
use crate::openhuman::memory::tree::source_tree::types::{TreeKind, TreeStatus};
use crate::openhuman::memory::tree::topic_tree::curator::maybe_spawn_topic_tree;
/// Route `leaf` into every active topic tree matching one of
/// `canonical_entities`. Also ticks the curator for each entity so the
/// next cadence-aligned ingest may spawn a new tree.
///
/// Returns `Ok(())` even if individual entities fail — per-entity errors
/// are logged. A hard DB failure early in the process is surfaced so the
/// caller can decide how loud to be in logs.
pub async fn route_leaf_to_topic_trees(
config: &Config,
leaf: &LeafRef,
canonical_entities: &[String],
summariser: &dyn Summariser,
) -> Result<()> {
if canonical_entities.is_empty() {
return Ok(());
}
log::debug!(
"[topic_tree::routing] leaf={} entities={}",
leaf.chunk_id,
canonical_entities.len()
);
for entity_id in canonical_entities {
if let Err(e) = route_one_entity(config, leaf, entity_id, summariser).await {
log::warn!(
"[topic_tree::routing] failed routing leaf={} entity={} err={:#}",
leaf.chunk_id,
entity_id,
e
);
}
}
Ok(())
}
async fn route_one_entity(
config: &Config,
leaf: &LeafRef,
entity_id: &str,
summariser: &dyn Summariser,
) -> Result<()> {
// Step 1: if a topic tree already exists and is active, append the leaf.
// We intentionally do this BEFORE asking the curator to spawn — a
// same-call spawn would also include this leaf via backfill
// (`lookup_entity` was just updated by the ingest's score persist) but
// keeping the existing-tree fast path separate keeps the common case
// (hot entity already has a tree) clean.
if let Some(tree) = src_store::get_tree_by_scope(config, TreeKind::Topic, entity_id)? {
if tree.status == TreeStatus::Active {
log::debug!(
"[topic_tree::routing] appending leaf={} → topic_tree={}",
leaf.chunk_id,
tree.id
);
// Rebuild the leaf with this entity-id stamped on so the seal
// path sees the topic membership. The source-tree append used
// the full entity list; here we scope to just this entity so
// the curated summariser (future) can prompt accordingly.
let topic_leaf = LeafRef {
entities: vec![entity_id.to_string()],
..leaf.clone()
};
append_leaf(config, &tree, &topic_leaf, summariser).await?;
} else {
log::debug!(
"[topic_tree::routing] skip archived topic tree id={} entity={}",
tree.id,
entity_id
);
}
}
// Step 2: curator tick — may spawn a new tree on cadence.
maybe_spawn_topic_tree(config, entity_id, summariser).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::memory::tree::score::extract::EntityKind;
use crate::openhuman::memory::tree::score::resolver::CanonicalEntity;
use crate::openhuman::memory::tree::score::store::index_entity;
use crate::openhuman::memory::tree::source_tree::summariser::inert::InertSummariser;
use crate::openhuman::memory::tree::store::upsert_chunks;
use crate::openhuman::memory::tree::topic_tree::registry::{
archive_topic_tree, get_or_create_topic_tree,
};
use crate::openhuman::memory::tree::topic_tree::store::get as get_hotness;
use crate::openhuman::memory::tree::types::{chunk_id, Chunk, Metadata, SourceKind, SourceRef};
use chrono::{TimeZone, Utc};
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
(tmp, cfg)
}
fn mk_leaf(chunk_id_s: &str, tokens: u32, ts_ms: i64) -> LeafRef {
LeafRef {
chunk_id: chunk_id_s.to_string(),
token_count: tokens,
timestamp: Utc.timestamp_millis_opt(ts_ms).unwrap(),
content: format!("content for {chunk_id_s}"),
entities: vec!["email:alice@example.com".into()],
topics: vec![],
score: 0.5,
}
}
fn persist_chunk(cfg: &Config, source_id: &str, seq: u32, ts_ms: i64, tokens: u32) -> String {
let ts = Utc.timestamp_millis_opt(ts_ms).unwrap();
let c = Chunk {
id: chunk_id(SourceKind::Chat, source_id, seq),
content: format!("chunk content {source_id} {seq}"),
metadata: Metadata {
source_kind: SourceKind::Chat,
source_id: source_id.to_string(),
owner: "alice".into(),
timestamp: ts,
time_range: (ts, ts),
tags: vec![],
source_ref: Some(SourceRef::new(format!("{source_id}://{seq}"))),
},
token_count: tokens,
seq_in_source: seq,
created_at: ts,
};
let id = c.id.clone();
upsert_chunks(cfg, &[c]).unwrap();
id
}
#[tokio::test]
async fn empty_entities_is_noop() {
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
let leaf = mk_leaf("c1", 10, 1_700_000_000_000);
route_leaf_to_topic_trees(&cfg, &leaf, &[], &summariser)
.await
.unwrap();
// No hotness rows were created.
assert_eq!(
crate::openhuman::memory::tree::topic_tree::store::count(&cfg).unwrap(),
0
);
}
#[tokio::test]
async fn appends_to_existing_topic_tree() {
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
// Pre-create the topic tree so the hot-path append fires.
let tree = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
// Persist the backing chunk so hydrate can read it on seal.
let chunk_id_s = persist_chunk(&cfg, "slack:#eng", 0, 1_700_000_000_000, 100);
let leaf = mk_leaf(&chunk_id_s, 100, 1_700_000_000_000);
route_leaf_to_topic_trees(
&cfg,
&leaf,
&["email:alice@example.com".to_string()],
&summariser,
)
.await
.unwrap();
let buf = src_store::get_buffer(&cfg, &tree.id, 0).unwrap();
assert_eq!(buf.item_ids.len(), 1);
assert_eq!(buf.item_ids[0], chunk_id_s);
// Counter should also be bumped.
let c = get_hotness(&cfg, "email:alice@example.com")
.unwrap()
.unwrap();
assert_eq!(c.mention_count_30d, 1);
}
#[tokio::test]
async fn archived_topic_tree_does_not_receive_new_leaves() {
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
let tree = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
archive_topic_tree(&cfg, &tree.id).unwrap();
let chunk_id_s = persist_chunk(&cfg, "slack:#eng", 0, 1_700_000_000_000, 100);
let leaf = mk_leaf(&chunk_id_s, 100, 1_700_000_000_000);
route_leaf_to_topic_trees(
&cfg,
&leaf,
&["email:alice@example.com".to_string()],
&summariser,
)
.await
.unwrap();
let buf = src_store::get_buffer(&cfg, &tree.id, 0).unwrap();
assert!(
buf.is_empty(),
"archived topic tree should not receive new leaves"
);
// Counter should still be bumped — archiving doesn't freeze hotness.
let c = get_hotness(&cfg, "email:alice@example.com")
.unwrap()
.unwrap();
assert_eq!(c.mention_count_30d, 1);
}
#[tokio::test]
async fn one_leaf_multiple_entities_fans_out() {
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
let t1 = get_or_create_topic_tree(&cfg, "email:alice@example.com").unwrap();
let t2 = get_or_create_topic_tree(&cfg, "hashtag:launch").unwrap();
let chunk_id_s = persist_chunk(&cfg, "slack:#eng", 0, 1_700_000_000_000, 100);
let leaf = mk_leaf(&chunk_id_s, 100, 1_700_000_000_000);
route_leaf_to_topic_trees(
&cfg,
&leaf,
&[
"email:alice@example.com".to_string(),
"hashtag:launch".to_string(),
],
&summariser,
)
.await
.unwrap();
// Both topic trees' L0 buffers hold the leaf.
let b1 = src_store::get_buffer(&cfg, &t1.id, 0).unwrap();
let b2 = src_store::get_buffer(&cfg, &t2.id, 0).unwrap();
assert_eq!(b1.item_ids.len(), 1);
assert_eq!(b2.item_ids.len(), 1);
}
#[tokio::test]
async fn integration_two_sources_mentioning_alice_materialise_topic_tree() {
// Phase 3c acceptance scenario: ingest across 2 sources mentioning
// Alice → hotness crosses threshold → topic tree materialised →
// new Alice-mentioning leaf routes into both the source tree AND
// the topic tree.
let (_tmp, cfg) = test_config();
let summariser = InertSummariser::new();
let entity_id = "email:alice@example.com";
// Pre-seed counters / index so the next call crosses threshold.
// Note: the curator refreshes `distinct_sources` from the entity
// index during recompute, so we also need enough `query_hits_30d`
// to keep hotness above `TOPIC_CREATION_THRESHOLD` once the index
// is queried (two indexed sources below → distinct_sources → 2).
let mut counters =
crate::openhuman::memory::tree::topic_tree::types::HotnessCounters::fresh(entity_id, 0);
counters.mention_count_30d = 1_000;
counters.distinct_sources = 2;
counters.last_seen_ms = Some(Utc::now().timestamp_millis());
counters.query_hits_30d = 5;
counters.ingests_since_check =
crate::openhuman::memory::tree::topic_tree::types::TOPIC_RECHECK_EVERY - 1;
crate::openhuman::memory::tree::topic_tree::store::upsert(&cfg, &counters).unwrap();
// Seed a leaf in slack and gmail referencing Alice.
let c1 = persist_chunk(&cfg, "slack:#eng", 0, 1_700_000_000_000, 100);
let c2 = persist_chunk(&cfg, "gmail:alice", 0, 1_700_000_010_000, 100);
let e = CanonicalEntity {
canonical_id: entity_id.into(),
kind: EntityKind::Email,
surface: entity_id.into(),
span_start: 0,
span_end: entity_id.len() as u32,
score: 1.0,
};
index_entity(&cfg, &e, &c1, "leaf", 1_700_000_000_000, Some("slack:#eng")).unwrap();
index_entity(
&cfg,
&e,
&c2,
"leaf",
1_700_000_010_000,
Some("gmail:alice"),
)
.unwrap();
// A third leaf arrives — should both fan out to (future) topic tree
// and push the curator over the recheck cadence, materialising it.
let c3 = persist_chunk(&cfg, "slack:#eng", 1, 1_700_000_020_000, 100);
let leaf = LeafRef {
chunk_id: c3.clone(),
token_count: 100,
timestamp: Utc.timestamp_millis_opt(1_700_000_020_000).unwrap(),
content: "new mention".into(),
entities: vec![entity_id.into()],
topics: vec![],
score: 0.5,
};
route_leaf_to_topic_trees(&cfg, &leaf, &[entity_id.to_string()], &summariser)
.await
.unwrap();
// Topic tree now exists.
let tree = src_store::get_tree_by_scope(&cfg, TreeKind::Topic, entity_id)
.unwrap()
.expect("topic tree should be materialised");
assert_eq!(tree.kind, TreeKind::Topic);
assert_eq!(tree.scope, entity_id);
// Backfill pulled c1 + c2 into the buffer. (c3 didn't get into the
// entity index during this test since we didn't run the full ingest
// path — we're exercising routing in isolation.)
let buf = src_store::get_buffer(&cfg, &tree.id, 0).unwrap();
assert!(
buf.item_ids.len() >= 2,
"backfill should pull historic leaves"
);
}
}
@@ -0,0 +1,245 @@
//! SQLite persistence for topic-tree-specific state (#709 Phase 3c).
//!
//! The only new table owned here is `mem_tree_entity_hotness` — the
//! per-entity counter block driving lazy materialisation. Tree rows and
//! summary nodes are reused from [`super::super::source_tree::store`] via
//! the shared `mem_tree_trees` / `mem_tree_summaries` / `mem_tree_buffers`
//! tables, which already carry a `kind` column that discriminates
//! `source` from `topic`. No schema additions for those tables in Phase
//! 3c — only the new hotness table.
//!
//! Schema for `mem_tree_entity_hotness` is declared in
//! [`super::super::store::SCHEMA`] (the sibling Phase 1 store file) so
//! migrations all run through the same `with_connection` entry point.
use anyhow::{Context, Result};
use chrono::Utc;
use rusqlite::{params, OptionalExtension};
use crate::openhuman::config::Config;
use crate::openhuman::memory::tree::store::with_connection;
use crate::openhuman::memory::tree::topic_tree::types::HotnessCounters;
/// Fetch the hotness row for `entity_id`, or `None` if the entity has
/// never been seen. Callers usually want [`get_or_fresh`] instead.
pub fn get(config: &Config, entity_id: &str) -> Result<Option<HotnessCounters>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT entity_id, mention_count_30d, distinct_sources, last_seen_ms,
query_hits_30d, graph_centrality, ingests_since_check,
last_hotness, last_updated_ms
FROM mem_tree_entity_hotness WHERE entity_id = ?1",
)?;
let row = stmt
.query_row(params![entity_id], row_to_counters)
.optional()
.context("failed to query mem_tree_entity_hotness")?;
Ok(row)
})
}
/// Fetch the hotness row, or return a fresh (all-zero) row if the entity
/// has never been seen. The fresh row is NOT persisted — callers must
/// [`upsert`] it explicitly after bumping counters.
pub fn get_or_fresh(config: &Config, entity_id: &str) -> Result<HotnessCounters> {
match get(config, entity_id)? {
Some(c) => Ok(c),
None => Ok(HotnessCounters::fresh(
entity_id,
Utc::now().timestamp_millis(),
)),
}
}
/// Upsert the full counter row. Idempotent on `entity_id`.
pub fn upsert(config: &Config, counters: &HotnessCounters) -> Result<()> {
with_connection(config, |conn| {
conn.execute(
"INSERT INTO mem_tree_entity_hotness (
entity_id, mention_count_30d, distinct_sources, last_seen_ms,
query_hits_30d, graph_centrality, ingests_since_check,
last_hotness, last_updated_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(entity_id) DO UPDATE SET
mention_count_30d = excluded.mention_count_30d,
distinct_sources = excluded.distinct_sources,
last_seen_ms = excluded.last_seen_ms,
query_hits_30d = excluded.query_hits_30d,
graph_centrality = excluded.graph_centrality,
ingests_since_check = excluded.ingests_since_check,
last_hotness = excluded.last_hotness,
last_updated_ms = excluded.last_updated_ms",
params![
counters.entity_id,
counters.mention_count_30d,
counters.distinct_sources,
counters.last_seen_ms,
counters.query_hits_30d,
counters.graph_centrality,
counters.ingests_since_check,
counters.last_hotness,
counters.last_updated_ms,
],
)
.with_context(|| {
format!(
"failed to upsert mem_tree_entity_hotness for {}",
counters.entity_id
)
})?;
Ok(())
})
}
/// Count `(node_id) → DISTINCT tree_id` in the entity index for `entity_id`.
/// Used by the curator to refresh `distinct_sources` during the periodic
/// hotness recompute without rescanning every chunk.
pub fn distinct_sources_for(config: &Config, entity_id: &str) -> Result<u32> {
with_connection(config, |conn| {
let n: i64 = conn
.query_row(
"SELECT COUNT(DISTINCT tree_id)
FROM mem_tree_entity_index
WHERE entity_id = ?1 AND tree_id IS NOT NULL",
params![entity_id],
|r| r.get(0),
)
.context("failed to count distinct sources")?;
Ok(n.max(0) as u32)
})
}
/// Test / diagnostic helper.
pub fn count(config: &Config) -> Result<u64> {
with_connection(config, |conn| {
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM mem_tree_entity_hotness", [], |r| {
r.get(0)
})
.context("failed to count mem_tree_entity_hotness")?;
Ok(n.max(0) as u64)
})
}
fn row_to_counters(row: &rusqlite::Row<'_>) -> rusqlite::Result<HotnessCounters> {
Ok(HotnessCounters {
entity_id: row.get(0)?,
mention_count_30d: row.get::<_, i64>(1)?.max(0) as u32,
distinct_sources: row.get::<_, i64>(2)?.max(0) as u32,
last_seen_ms: row.get(3)?,
query_hits_30d: row.get::<_, i64>(4)?.max(0) as u32,
graph_centrality: row.get(5)?,
ingests_since_check: row.get::<_, i64>(6)?.max(0) as u32,
last_hotness: row.get(7)?,
last_updated_ms: row.get(8)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
(tmp, cfg)
}
#[test]
fn get_missing_is_none() {
let (_tmp, cfg) = test_config();
assert!(get(&cfg, "email:alice@example.com").unwrap().is_none());
}
#[test]
fn get_or_fresh_returns_zero_row() {
let (_tmp, cfg) = test_config();
let c = get_or_fresh(&cfg, "email:alice@example.com").unwrap();
assert_eq!(c.entity_id, "email:alice@example.com");
assert_eq!(c.mention_count_30d, 0);
assert_eq!(c.distinct_sources, 0);
assert!(c.last_hotness.is_none());
// Not persisted — still zero rows in the table.
assert_eq!(count(&cfg).unwrap(), 0);
}
#[test]
fn upsert_round_trip() {
let (_tmp, cfg) = test_config();
let c = HotnessCounters {
entity_id: "email:alice@example.com".into(),
mention_count_30d: 12,
distinct_sources: 3,
last_seen_ms: Some(1_700_000_000_000),
query_hits_30d: 2,
graph_centrality: Some(0.25),
ingests_since_check: 40,
last_hotness: Some(9.5),
last_updated_ms: 1_700_000_123_000,
};
upsert(&cfg, &c).unwrap();
let got = get(&cfg, &c.entity_id).unwrap().unwrap();
assert_eq!(got, c);
assert_eq!(count(&cfg).unwrap(), 1);
}
#[test]
fn upsert_is_idempotent_and_updates_fields() {
let (_tmp, cfg) = test_config();
let mut c = HotnessCounters::fresh("email:alice@example.com", 0);
c.mention_count_30d = 1;
upsert(&cfg, &c).unwrap();
c.mention_count_30d = 99;
c.last_updated_ms = 500;
upsert(&cfg, &c).unwrap();
assert_eq!(count(&cfg).unwrap(), 1);
let got = get(&cfg, "email:alice@example.com").unwrap().unwrap();
assert_eq!(got.mention_count_30d, 99);
assert_eq!(got.last_updated_ms, 500);
}
#[test]
fn distinct_sources_counts_trees() {
use crate::openhuman::memory::tree::score::extract::EntityKind;
use crate::openhuman::memory::tree::score::resolver::CanonicalEntity;
use crate::openhuman::memory::tree::score::store::index_entity;
let (_tmp, cfg) = test_config();
let e = CanonicalEntity {
canonical_id: "email:alice@example.com".into(),
kind: EntityKind::Email,
surface: "alice@example.com".into(),
span_start: 0,
span_end: 17,
score: 1.0,
};
index_entity(&cfg, &e, "chunk-1", "leaf", 1000, Some("source:slack")).unwrap();
index_entity(&cfg, &e, "chunk-2", "leaf", 2000, Some("source:gmail")).unwrap();
index_entity(&cfg, &e, "chunk-3", "leaf", 3000, Some("source:slack")).unwrap();
// 3 rows but only 2 distinct tree_ids.
let n = distinct_sources_for(&cfg, "email:alice@example.com").unwrap();
assert_eq!(n, 2);
}
#[test]
fn distinct_sources_ignores_null_tree_id() {
use crate::openhuman::memory::tree::score::extract::EntityKind;
use crate::openhuman::memory::tree::score::resolver::CanonicalEntity;
use crate::openhuman::memory::tree::score::store::index_entity;
let (_tmp, cfg) = test_config();
let e = CanonicalEntity {
canonical_id: "email:alice@example.com".into(),
kind: EntityKind::Email,
surface: "alice@example.com".into(),
span_start: 0,
span_end: 17,
score: 1.0,
};
// tree_id = None — should not count toward distinct_sources.
index_entity(&cfg, &e, "chunk-1", "leaf", 1000, None).unwrap();
index_entity(&cfg, &e, "chunk-2", "leaf", 2000, Some("source:slack")).unwrap();
let n = distinct_sources_for(&cfg, "email:alice@example.com").unwrap();
assert_eq!(n, 1);
}
}
@@ -0,0 +1,150 @@
//! Core types for Phase 3c — lazy topic-tree materialisation (#709).
//!
//! A *topic tree* is a per-entity summary tree whose leaves are all chunks
//! mentioning that entity, regardless of the source they came from. They
//! are materialised lazily, driven by a cheap arithmetic *hotness* score
//! over pre-existing signals. Tree mechanics (buffer / seal / cascade)
//! reuse [`source_tree`] end-to-end — topic trees only differ by the
//! `TreeKind::Topic` discriminator and the per-entity `scope`.
//!
//! This file defines:
//! - [`EntityIndexStats`] — input record for the hotness calculation
//! - [`HotnessCounters`] — the persisted row in `mem_tree_entity_hotness`
//! - threshold / cadence constants ([`TOPIC_CREATION_THRESHOLD`],
//! [`TOPIC_ARCHIVE_THRESHOLD`], [`TOPIC_RECHECK_EVERY`])
//!
//! Persistence helpers for these types live in [`super::store`].
use serde::{Deserialize, Serialize};
/// Hotness threshold above which a topic tree is materialised for an
/// entity. Tuned (per design) to roughly "several mentions across a few
/// sources" — see [`super::hotness::hotness`] for the formula.
pub const TOPIC_CREATION_THRESHOLD: f32 = 10.0;
/// Hotness threshold below which a topic tree becomes an archive candidate.
/// Archiving is a primitive in Phase 3c — the scheduled sweep is deferred.
pub const TOPIC_ARCHIVE_THRESHOLD: f32 = 2.0;
/// How often (in ingests touching the entity) to recompute hotness from
/// the full [`EntityIndexStats`]. Between recomputes we only bump
/// `mention_count_30d` and `last_seen_ms` — cheap integer arithmetic.
pub const TOPIC_RECHECK_EVERY: u32 = 100;
/// Input record fed to [`super::hotness::hotness`].
///
/// Every field is a signal that already exists somewhere in the memory
/// tree (scoring rows, entity index, potential future graph metrics); the
/// struct is an explicit contract so the hotness math can be unit-tested
/// in isolation without touching SQLite.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct EntityIndexStats {
/// Total mentions in the last 30 days. Phase 3c currently bumps this
/// forever — the 30d window is a TODO once we have a billable clock.
pub mention_count_30d: u32,
/// Number of distinct source trees this entity has appeared in. A
/// cross-source signal — an entity spoken about in one chat channel
/// but nowhere else is less interesting than one that appears in
/// Slack + email + docs.
pub distinct_sources: u32,
/// Epoch-millis of the last ingest that referenced this entity.
pub last_seen_ms: Option<i64>,
/// Reserved for Phase 4 retrieval: bump whenever a user query returns
/// this entity. Phase 3c stores the column but never increments it.
pub query_hits_30d: u32,
/// Reserved for a later phase: graph centrality from the entity graph.
/// `None` means "unknown" — not "zero". See [`super::hotness::hotness`].
pub graph_centrality: Option<f32>,
}
/// Row persisted in `mem_tree_entity_hotness`. Callers interact with this
/// through [`super::store`]; [`EntityIndexStats`] is the hotness-compute
/// view derived from it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HotnessCounters {
pub entity_id: String,
pub mention_count_30d: u32,
pub distinct_sources: u32,
pub last_seen_ms: Option<i64>,
pub query_hits_30d: u32,
pub graph_centrality: Option<f32>,
/// Counts ingests **touching this entity** since the last full hotness
/// recompute. When `>= TOPIC_RECHECK_EVERY` the curator refreshes
/// `distinct_sources` / `last_hotness` and resets this to 0.
pub ingests_since_check: u32,
pub last_hotness: Option<f32>,
pub last_updated_ms: i64,
}
impl HotnessCounters {
/// Fresh row for an entity seen for the first time.
pub fn fresh(entity_id: &str, now_ms: i64) -> Self {
Self {
entity_id: entity_id.to_string(),
mention_count_30d: 0,
distinct_sources: 0,
last_seen_ms: None,
query_hits_30d: 0,
graph_centrality: None,
ingests_since_check: 0,
last_hotness: None,
last_updated_ms: now_ms,
}
}
/// Project the persisted row into an [`EntityIndexStats`] ready for
/// [`super::hotness::hotness`].
pub fn stats(&self) -> EntityIndexStats {
EntityIndexStats {
mention_count_30d: self.mention_count_30d,
distinct_sources: self.distinct_sources,
last_seen_ms: self.last_seen_ms,
query_hits_30d: self.query_hits_30d,
graph_centrality: self.graph_centrality,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_counters_are_zero() {
let c = HotnessCounters::fresh("email:alice@example.com", 1_700_000_000_000);
assert_eq!(c.entity_id, "email:alice@example.com");
assert_eq!(c.mention_count_30d, 0);
assert_eq!(c.distinct_sources, 0);
assert_eq!(c.ingests_since_check, 0);
assert!(c.last_hotness.is_none());
assert!(c.last_seen_ms.is_none());
assert_eq!(c.last_updated_ms, 1_700_000_000_000);
}
#[test]
fn stats_projection_mirrors_row() {
let c = HotnessCounters {
entity_id: "e".into(),
mention_count_30d: 5,
distinct_sources: 2,
last_seen_ms: Some(42),
query_hits_30d: 1,
graph_centrality: Some(0.3),
ingests_since_check: 4,
last_hotness: Some(9.9),
last_updated_ms: 100,
};
let s = c.stats();
assert_eq!(s.mention_count_30d, 5);
assert_eq!(s.distinct_sources, 2);
assert_eq!(s.last_seen_ms, Some(42));
assert_eq!(s.query_hits_30d, 1);
assert_eq!(s.graph_centrality, Some(0.3));
}
#[test]
fn thresholds_make_creation_strictly_above_archive() {
assert!(TOPIC_CREATION_THRESHOLD > TOPIC_ARCHIVE_THRESHOLD);
assert!(TOPIC_RECHECK_EVERY > 0);
}
}