feat(learning): ambient personalization cache for #566 (#1460)

This commit is contained in:
sanil-23
2026-05-10 09:45:40 -07:00
committed by GitHub
parent 065e198cf4
commit 12ef69b89f
31 changed files with 7656 additions and 146 deletions
+368
View File
@@ -0,0 +1,368 @@
# Agent self-learning
OpenHuman learns user preferences continuously and surfaces them as ambient defaults in every system prompt. The mechanism is a small **personalization cache** materialized from multiple deterministic + LLM-driven producers, scored by stability, rendered into a user-editable `PROFILE.md`, and injected into prompts through the existing prompt-section pipeline.
This document covers how preferences are captured, scored, persisted, and surfaced. For the originating issue, see [#566](https://github.com/tinyhumansai/openhuman/issues/566).
---
## What gets learned
Six classes, encoded in the `key` prefix and stored in the existing `user_profile_facets` table:
| Class | Example facets |
|---|---|
| `style/*` | `verbosity=terse`, `format=bullets`, `preamble=skip`, `language=english`, `emoji=skip` |
| `identity/*` | `name=Sanil`, `timezone=PST`, `role=engineer`, `employer=vezures` |
| `tooling/*` | `package_manager=pnpm`, `lang=rust`, `framework=astro`, `runtime=bun` |
| `veto/*` | `tool=jest → banned`, `format=nested-bullets → banned` |
| `goal/*` | free-form goal sentences (slugified key) |
| `channel/preference` | `primary=desktop-chat` |
Recurring topics, recurring entities, and prior threads are **not** in the cache. They live in the memory tree and are retrieved per-turn by `memory_recall` under the prompt-bias instruction described below.
---
## Architecture
Four stages: **capture → identify → score → materialize**.
```
inputs substrate candidates cache
───── ───────── ────────── ─────
chat turns ──→ episodic_log ──→ Buffer (push) ──┐
+ tree │
skill syncs ──→ tree (sources) ──→ Buffer (push) ──┤
├─→ user_profile_facets
channel inbound ──→ tree ──→ Buffer (push) ──┤ (state, stability,
│ user_state, evidence)
documents ──→ tree ──→ Buffer (push) ──┘ │
stability_detector::rebuild CacheRebuilt event
(every 30 min + event-driven) │
ProfileMdRenderer
PROFILE.md
(managed blocks)
UserFilesSection
+ UserProfileSection
+ MemoryAccessSection
agent system prompt
```
Chat turns flow into the tree as `source_id="conversations:agent"` (with `tool_calls_json` stripped at canonicalize), so the same `tree_source::summariser` that already runs over Slack/Gmail/Notion now also produces summaries — and structured facet candidates — over chat content.
---
## Producers
Five producers write `LearningCandidate` values into `learning::candidate::global()`:
| # | Producer | Emits | LLM? |
|---|---|---|---|
| 1 | `composio::providers::profile::persist_provider_profile` (existing) | Identity from Gmail/Slack/Notion account fields | No |
| 2 | `learning::extract::signature::EmailSignatureSubscriber` | Identity from email signatures (last 8 lines) | No |
| 3 | `learning::extract::heuristics` (`LengthRatioDetector`, `EditWindowDetector`, `CorrectionRepeatDetector`) | Style + Veto from per-turn rolling state | No |
| 4 | `learning::ReflectionHook` (rerouted) | Goal + Style from heuristic cues and LLM-extracted reflections | Optional |
| 5 | `tree_source::summariser::llm` (extended schema) | All classes — long-tail extraction over rolling per-source summaries | Yes (existing call, extended output) |
Producer 5 is the long-tail backbone. Its prompt now asks the model to emit `{ summary, facets[] }`, and `learning::extract::summary_facets` validates each `ParsedFacet` (canonical class, mandatory `evidence_chunks`) before pushing to the buffer. **No new LLM calls** are introduced — the change extends an existing summarization round-trip with ~150400 output tokens.
Explicitly **not** producing:
- Hand-curated regex catalogs for style/identity/tooling — Producer 5 covers them and generalizes to vocabulary the catalog wouldn't predict.
- Hand-curated manifest parsers for tooling — same reasoning; Astro, Bun, Deno, uv, mise, etc. all surface through the LLM summarizer.
- Free-text NER on chat — recurring entities live in the tree's graph (from structured provider metadata) and surface contextually via `memory_recall`.
- `ToolTrackerHook` candidate emission — failure-rate is not a clean preference signal; it stays as substrate for debugging.
---
## Identification → candidate buffer
Every emission carries provenance:
```rust
pub struct LearningCandidate {
pub class: FacetClass, // Style | Identity | Tooling | Veto | Goal | Channel
pub key: String, // canonical slug, e.g. "verbosity"
pub value: String, // canonical value, e.g. "terse"
pub cue_family: CueFamily, // Explicit | Structural | Behavioral | Recurrence
pub evidence: EvidenceRef, // pointer back into substrate
pub initial_confidence: f64, // 0..=1, source-provided hint
pub observed_at: f64, // epoch seconds
}
```
`EvidenceRef` variants cover every substrate origin (`Episodic`, `EpisodicWindow`, `SourceSummary`, `TreeTopic`, `DocumentChunk`, `EmailMessage`, `Provider`, `ToolCall`, `TreeSourceWeight`). The buffer is a thread-safe bounded ring (default capacity 1024). The stability detector drains it every rebuild.
---
## Stability scoring
```
stability(class, key, value) = base × cue × user_state
base = Σ over evidence:
cue_family_weight × exp(-Δt / half_life_for_class) × log(1 + evidence_count_for_family)
cue = 2.0 if any evidence is Explicit, else 1.0
user = ∞ if Pinned, 0 if Forgotten, 1 otherwise
```
**Cue-family weights**:
| Family | Weight | Rationale |
|---|---|---|
| `Explicit` | 1.0 | direct user statement — declaration is intent |
| `Structural` | 0.9 | provider data / manifest content / signature — data doesn't lie |
| `Behavioral` | 0.7 | heuristics + summary mining — must accumulate |
| `Recurrence` | 0.6 | tree statistics — emerging |
**Class half-lives** (the time over which evidence weight halves):
| Class | Half-life |
|---|---|
| `identity` | 90 days |
| `veto` | 60 days |
| `tooling` | 30 days |
| `goal` | 30 days |
| `style` | 14 days |
| `channel` | 7 days |
**Conflict resolution per `(class, key)`**: active value = `argmax(stability)` over candidate values. Losing values are dropped from the cache; if they re-emerge they reinforce naturally through the same path.
---
## States and qualification
Two state columns govern visibility:
`state` (driven by stability):
| State | Range | Effect |
|---|---|---|
| `Active` | `stability ≥ τ_promote` (1.5) | Renders in `PROFILE.md`, injected into prompt |
| `Provisional` | `0.7 ≤ stability < 1.5` | Stored, not rendered |
| `Candidate` | `0.4 ≤ stability < 0.7` | Stays in buffer, not yet in cache |
| `Dropped` | `stability < 0.4` | Removed |
`user_state` (overrides scoring):
| State | Effect |
|---|---|
| `Auto` | Default; `state` follows scoring |
| `Pinned` | Locks `Active`; resists decay |
| `Forgotten` | Locks `Dropped`; blocks re-promotion |
**Class budgets** (top-N selection per class with a shared overflow pool):
```
style 4 · identity 4 · tooling 5 · veto 3 · goal 3 · channel 1 · overflow 5
total cap ≈ 25
```
Per-class budgets prevent a tooling-heavy user from drowning out style and identity; the overflow pool lets one class take spare capacity from another when underused.
---
## Storage
Single SQLite table — `user_profile_facets` (existing, extended by an idempotent migration in `memory::store::unified::profile::migrate_profile_schema`):
```sql
CREATE TABLE user_profile (
facet_id TEXT PRIMARY KEY,
facet_type TEXT NOT NULL, -- legacy enum
key TEXT NOT NULL,
value TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0.5,
evidence_count INTEGER NOT NULL DEFAULT 1,
source_segment_ids TEXT,
first_seen_at REAL NOT NULL,
last_seen_at REAL NOT NULL,
-- learning-cache columns (added by migrate_profile_schema):
state TEXT NOT NULL DEFAULT 'active',
stability REAL NOT NULL DEFAULT 0.0,
user_state TEXT NOT NULL DEFAULT 'auto',
evidence_refs_json TEXT,
class TEXT,
cue_families_json TEXT,
UNIQUE(facet_type, key)
);
CREATE INDEX idx_profile_state ON user_profile(state);
CREATE INDEX idx_profile_class ON user_profile(class);
```
The provider-profile path (`composio::providers::profile::persist_provider_profile`) continues to write its existing rows untouched; class is auto-derived for backward-compatibility (provider keys like `skill:gmail:default:email` map to `class=tooling`/`identity`).
---
## Surface — `PROFILE.md`
`{workspace_dir}/PROFILE.md` carries multiple managed blocks. Each block is owned by automation; content outside the markers is user-authored and preserved across rebuilds.
```markdown
# User Profile
<!-- openhuman:style:start -->
## Style
- **verbosity**: terse
- **preamble**: skip *(pinned)*
<!-- openhuman:style:end -->
<!-- openhuman:identity:start -->
## Identity
- **name**: Sanil
- **timezone**: PST
<!-- openhuman:identity:end -->
<!-- openhuman:tooling:start -->
## Tooling
- **lang**: rust
- **package_manager**: pnpm
<!-- openhuman:tooling:end -->
<!-- openhuman:vetoes:start -->
## Vetoes
- **tool=jest**: banned
<!-- openhuman:vetoes:end -->
<!-- openhuman:goals:start -->
## Goals
- ship #566 before #686
<!-- openhuman:goals:end -->
<!-- openhuman:connected-accounts:start -->
## Connected Accounts
- gmail (sanil@vezures.xyz)
<!-- openhuman:connected-accounts:end -->
```
User-authored content between blocks (free-form notes, hand-edited details) is preserved verbatim across rebuilds.
`ProfileMdRenderer` subscribes to `DomainEvent::CacheRebuilt` and rewrites the cache-derived blocks (`style`, `identity`, `tooling`, `vetoes`, `goals`). The `connected-accounts` block remains owned by the provider sync path.
---
## Surface — prompt sections
Three sections in `agent/prompts/` cooperate:
- **`UserFilesSection`** — already-existing; injects `PROFILE.md` verbatim into the system prompt every turn.
- **`UserProfileSection`** — repointed in Phase 4 to read `FacetCache::list_active()` (gated by `LearningConfig::use_cache_for_user_profile_section`, default true). Renders `- **{class}/{key}**: {value}` bullets.
- **`MemoryAccessSection`** — new in Phase 4; static instruction biasing the agent to call `memory_recall` for entities, threads, prior decisions, recurring topics. Adds ~80 tokens to the system prompt.
The `MemoryAccessSection` covers the **contextual** half of personalization that the cache structurally cannot address: things that need to be retrieved when relevant (recurring people, prior threads) rather than always rendered.
---
## Surface — RPC controllers
Wired through the controller registry (`learning::schemas::all_learning_controller_schemas`):
| RPC | Purpose |
|---|---|
| `learning.list_facets { class }` | Enumerate the cache, optionally filtered by class. Returns active + provisional entries with provenance. |
| `learning.get_facet { class, key }` | Single-entry lookup. |
| `learning.update_facet { class, key, value }` | Overwrite the active value; auto-pins to prevent rebuild from clobbering. |
| `learning.pin_facet { class, key }` | `user_state = Pinned`. Locks `Active` regardless of stability score. |
| `learning.unpin_facet { class, key }` | `user_state = Auto`. |
| `learning.forget_facet { class, key }` | `user_state = Forgotten`. Locks `Dropped`, blocks re-promotion. |
| `learning.reset_cache {}` | Clears all `Auto` rows; preserves `Pinned`. Next rebuild repopulates from substrate. |
| `learning.rebuild_cache {}` | Manual trigger for the stability rebuild. |
| `learning.cache_stats {}` | `{ total, active, provisional, candidate, dropped, by_class }`. |
The agent itself acts as a conversational user-control surface: asked "what do you know about me?" it can call `list_facets` and cite `evidence_refs_json` for each entry; "forget that I prefer terse" calls `forget_facet("style", "verbosity")`.
---
## Configuration
`LearningConfig` in `src/openhuman/config/schema/learning.rs`:
```rust
pub struct LearningConfig {
pub enabled: bool, // master switch
pub reflection_enabled: bool,
pub user_profile_enabled: bool, // legacy hook
pub tool_tracking_enabled: bool, // substrate-only
pub reflection_source: ReflectionSource, // Local | Cloud
pub max_reflections_per_session: usize,
pub min_turn_complexity: usize,
pub chat_to_tree_enabled: bool, // pipe agent chat into tree
pub stability_detector_enabled: bool,
pub rebuild_interval_secs: u64, // default 1800 (30 min)
pub use_cache_for_user_profile_section: bool, // route prompt-section reads through cache
}
```
The summarizer-side facet emission is gated by `LlmSummariserConfig::structured_facet_extraction` (default `true`) so production deployments can disable structured extraction independently of the rest of the learning subsystem.
---
## Observability
`DomainEvent::CacheRebuilt { added, evicted, kept, total_size, rebuilt_at }` is published after every successful rebuild. Subscribers can wire personalization metrics — for example, facet-in-prompt × positive-acceptance rate — to satisfy #566's "measurable personalization improvements" criterion.
`learning.cache_stats` returns the breakdown by state and class for ad-hoc inspection.
Tracing prefixes used by new flows (filter your log stream with these):
- `[learning::candidate]`
- `[learning::extract::signature]`
- `[learning::extract::heuristics]`
- `[learning::extract::summary_facets]`
- `[learning::stability_detector]`
- `[learning::cache]`
- `[learning::profile_md]`
- `[archivist]` (chat-into-tree path)
- `[memory::tree::ingest]` (`DocumentCanonicalized` emission)
---
## Testing
Unit tests live next to their modules (`*_tests.rs` siblings; consistent with the rest of the codebase). End-to-end coverage in `tests/learning_phase4_integration_test.rs`:
- Push candidates → `stability_detector.rebuild()` → expected `CacheRebuilt` event published
- `ProfileMdRenderer` writes the expected managed blocks into `PROFILE.md`
- `learning.pin_facet` keeps an entry `Active` despite weak evidence
- `learning.forget_facet` removes an entry from `PROFILE.md` and blocks re-promotion
- `learning.list_facets` returns the expected shape and class filter
Run targeted suites:
```bash
cargo test --manifest-path Cargo.toml --lib learning::
cargo test --manifest-path Cargo.toml --lib memory::store::unified::profile
cargo test --manifest-path Cargo.toml --test learning_phase4_integration_test
```
---
## Future work
- **Validation loop** — track facet-in-prompt × positive-acceptance to calibrate cue-family weights from outcome signal rather than fixed defaults.
- **Confirmation prompts** — for high-impact, low-confidence facets (Identity, Veto), surface to the user via the agent or subconscious before promoting to `Active`.
- **Cross-corpus topic stitching** — once chat-as-tree-source has accumulated, surface unified hot topics back into the prompt as contextual hints alongside the cache.
- **Per-channel persona facets** — tooling and style differ by channel; split when signals diverge meaningfully (e.g., terser in Slack DMs, more structured in email).
- **Tool-suggestion telemetry** — distinct from the existing failure stats; needs new `ToolSuggestionAccepted`/`ToolSuggestionRejected` events before it can produce clean veto signal.
- **Soft user confirmation in PROFILE.md** — render `Provisional` rows under a quieter sub-heading so the user can promote them by editing without waiting for accumulation.
+53 -1
View File
@@ -353,6 +353,55 @@ pub enum DomainEvent {
routed: bool,
},
// ── Memory tree ─────────────────────────────────────────────────────
/// A document (chat batch, email thread, or standalone document) was
/// fully canonicalised and its chunks written to the memory tree.
///
/// Emitted by `memory::tree::ingest::persist()` after the chunk upsert
/// and extract-job enqueue complete. Subscribers (Phase 2 producers such
/// as the email-signature parser) react to this to inspect the
/// canonicalised content.
DocumentCanonicalized {
/// The source identifier passed to the ingest call (e.g. `"gmail:abc"`,
/// `"conversations:agent"`).
source_id: String,
/// Kind of content — `"chat"`, `"email"`, `"document"`.
source_kind: String,
/// Number of chunks written to `vector_chunks` in this ingest.
chunks_written: usize,
/// IDs of the chunks that were written.
chunk_ids: Vec<String>,
/// Wall-clock seconds since epoch when canonicalisation completed.
canonicalized_at: f64,
/// Last ≤ 2 048 characters of the canonicalised markdown body.
///
/// Populated for `email` and `document` sources so that lightweight
/// subscribers (e.g. the email-signature parser) can inspect trailing
/// content without hitting disk. `None` for `chat` sources where the
/// content is conversational and doesn't contain signature-style structure.
body_preview: Option<String>,
},
// ── Learning ─────────────────────────────────────────────────────────
/// The stability detector finished a full cache rebuild cycle.
///
/// Emitted by `learning::stability_detector` (Phase 3) after writing
/// the new snapshot to `user_profile_facets`. Subscribers (Phase 4
/// `profile_md_renderer`) react to re-render the `PROFILE.md` managed
/// blocks.
CacheRebuilt {
/// Number of facets added in this cycle.
added: usize,
/// Number of facets evicted (below τ_evict threshold) in this cycle.
evicted: usize,
/// Number of facets unchanged / carried over.
kept: usize,
/// Total facets in the cache after the rebuild.
total_size: usize,
/// Wall-clock seconds since epoch when the rebuild completed.
rebuilt_at: f64,
},
// ── System lifecycle ────────────────────────────────────────────────
/// A system component started up.
SystemStartup { component: String },
@@ -389,7 +438,10 @@ impl DomainEvent {
| Self::MemoryRecalled { .. }
| Self::MemorySyncRequested { .. }
| Self::MemoryIngestionStarted { .. }
| Self::MemoryIngestionCompleted { .. } => "memory",
| Self::MemoryIngestionCompleted { .. }
| Self::DocumentCanonicalized { .. } => "memory",
Self::CacheRebuilt { .. } => "learning",
Self::ChannelInboundMessage { .. }
| Self::ChannelMessageReceived { .. }
+23
View File
@@ -415,6 +415,29 @@ fn all_variants_have_correct_domain() {
},
"system",
),
// Memory tree
(
DomainEvent::DocumentCanonicalized {
source_id: "gmail:abc".into(),
source_kind: "email".into(),
chunks_written: 3,
chunk_ids: vec!["c1".into(), "c2".into(), "c3".into()],
canonicalized_at: 1_700_000_000.0,
body_preview: Some("Thanks,\nAlice".into()),
},
"memory",
),
// Learning
(
DomainEvent::CacheRebuilt {
added: 2,
evicted: 1,
kept: 5,
total_size: 7,
rebuilt_at: 1_700_000_000.0,
},
"learning",
),
];
for (event, expected_domain) in cases {
+186
View File
@@ -6,14 +6,19 @@
//! 2. Manages conversation segments (boundary detection + lifecycle).
//! 3. On segment close: extracts events (heuristic) and updates user profile.
//! 4. Extracts simple lessons from tool failures.
//! 5. (Phase 1 / #566) Pipes the turn into the memory tree as `conversations:agent`
//! when `config.learning.chat_to_tree_enabled` is true.
use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext};
use crate::openhuman::config::Config;
use crate::openhuman::memory::store::events::{self, EventRecord, EventType};
use crate::openhuman::memory::store::fts5::{self, EpisodicEntry};
use crate::openhuman::memory::store::profile::{self, FacetType};
use crate::openhuman::memory::store::segments::{
self, BoundaryConfig, BoundaryDecision, ConversationSegment,
};
use crate::openhuman::memory::tree::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory::tree::ingest;
use async_trait::async_trait;
use parking_lot::Mutex;
use rusqlite::Connection;
@@ -31,24 +36,43 @@ pub struct ArchivistHook {
enabled: bool,
/// Boundary detection configuration.
boundary_config: BoundaryConfig,
/// Optional runtime config — used to gate the tree-ingest path.
///
/// When `None`, the tree-ingest path is skipped. Set via
/// [`ArchivistHook::with_config`] on the production path.
config: Option<Config>,
}
impl ArchivistHook {
/// Create an Archivist hook with a shared SQLite connection.
///
/// Tree-ingest is disabled by default; call [`Self::with_config`] to
/// enable it on the production path.
pub fn new(conn: Arc<Mutex<Connection>>, enabled: bool) -> Self {
Self {
conn: Some(conn),
enabled,
boundary_config: BoundaryConfig::default(),
config: None,
}
}
/// Attach runtime config so the archivist can gate the tree-ingest path.
///
/// When `config.learning.chat_to_tree_enabled` is `true`, each completed
/// turn is also piped into the memory tree as `source="conversations:agent"`.
pub fn with_config(mut self, config: Config) -> Self {
self.config = Some(config);
self
}
/// Create a disabled/no-op Archivist (when FTS5 is not enabled).
pub fn disabled() -> Self {
Self {
conn: None,
enabled: false,
boundary_config: BoundaryConfig::default(),
config: None,
}
}
@@ -365,11 +389,173 @@ impl PostTurnHook for ArchivistHook {
current_episodic_id,
);
// ── Phase 1 / #566: pipe turn into the memory tree ───────────────────
// Gate: only when config is attached and chat_to_tree_enabled is true.
// Non-fatal: if tree-ingest fails, the episodic write already succeeded
// and the turn result is not affected.
if let Some(ref cfg) = self.config {
if cfg.learning.chat_to_tree_enabled {
tracing::debug!(
"[archivist] piping turn into tree as conversations:agent session={}",
session_id
);
self.pipe_turn_to_tree(cfg, ctx, session_id, timestamp)
.await;
}
}
tracing::debug!("[archivist] turn indexed successfully");
Ok(())
}
}
impl ArchivistHook {
/// Pipe the completed turn into the memory tree as `source="conversations:agent"`.
///
/// Tool-call JSON is stripped from the assistant text before ingest — only
/// the assistant's prose response flows into the tree (memory ingestion
/// policy: tool outputs must not reach memory).
///
/// Failures are logged and swallowed; the episodic write is the source of
/// truth.
async fn pipe_turn_to_tree(
&self,
config: &Config,
ctx: &TurnContext,
session_id: &str,
timestamp: f64,
) {
use chrono::{TimeZone, Utc};
// Build turn timestamps. The assistant message is offset by 1ms as in
// the episodic write so ordering is stable.
let user_ts = Utc
.timestamp_opt(
timestamp as i64,
((timestamp.fract() * 1e9) as u32).min(999_999_999),
)
.single()
.unwrap_or_else(Utc::now);
let asst_ts = Utc
.timestamp_opt(
(timestamp + 0.001) as i64,
(((timestamp + 0.001).fract() * 1e9) as u32).min(999_999_999),
)
.single()
.unwrap_or(user_ts);
// Strip tool-call JSON from the assistant response.
// Per memory ingestion policy, structured tool-call payloads must not
// flow into the tree — only the prose response is ingested.
let assistant_prose = strip_tool_calls_from_response(&ctx.assistant_response);
let batch = ChatBatch {
platform: "agent".into(),
channel_label: session_id.to_string(),
messages: vec![
ChatMessage {
author: "user".into(),
timestamp: user_ts,
text: ctx.user_message.clone(),
source_ref: Some(format!("agent://session/{session_id}")),
},
ChatMessage {
author: "assistant".into(),
timestamp: asst_ts,
text: assistant_prose,
source_ref: Some(format!("agent://session/{session_id}")),
},
],
};
// Use the session_id as the owner / identity tag.
let source_id = "conversations:agent";
let owner = session_id;
let tags = vec!["agent_chat".to_string()];
match ingest::ingest_chat(config, source_id, owner, tags, batch).await {
Ok(result) => {
tracing::debug!(
"[archivist] tree ingest ok: source_id={} chunks_written={} session={}",
source_id,
result.chunks_written,
session_id
);
}
Err(e) => {
tracing::warn!(
"[archivist] tree ingest failed (non-fatal): source_id={} session={} error={e}",
source_id,
session_id
);
}
}
}
}
/// Strip tool-call JSON blocks from an assistant response, leaving only the
/// prose text.
///
/// The archivist stores the full response (including `tool_calls_json`) in
/// the episodic log for diagnostic purposes. However, per the memory
/// ingestion policy, structured tool-call payloads must not reach the memory
/// tree — only the assistant's natural-language prose is ingested.
///
/// This function applies a lightweight heuristic: it removes any contiguous
/// spans of text that look like `<tool_call>…</tool_call>` XML/JSON blocks or
/// raw JSON objects that begin with `{"tool_calls":`. The output may be empty
/// if the entire response was tool-call markup — callers should handle that
/// case (empty text → no-op ingest).
fn strip_tool_calls_from_response(response: &str) -> String {
// Fast path: if the response contains no obvious tool-call markers, return
// it unchanged to avoid unnecessary allocation.
if !response.contains("<tool_call>")
&& !response.contains("{\"tool_calls\"")
&& !response.contains("\"tool_use\"")
{
return response.to_string();
}
// Remove XML-style tool-call blocks.
let mut cleaned = response.to_string();
// Strip <tool_call>…</tool_call> spans (may span multiple lines).
while let Some(start) = cleaned.find("<tool_call>") {
if let Some(end) = cleaned[start..].find("</tool_call>") {
cleaned.drain(start..start + end + "</tool_call>".len());
} else {
// Unclosed tag — remove from the tag to end of string.
cleaned.truncate(start);
break;
}
}
// Trim and collapse runs of blank lines left by block removal.
let trimmed = cleaned
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n");
// Collapse more than two consecutive newlines to two.
let mut result = String::with_capacity(trimmed.len());
let mut blank_run = 0usize;
for line in trimmed.lines() {
if line.is_empty() {
blank_run += 1;
if blank_run <= 2 {
result.push('\n');
}
} else {
blank_run = 0;
result.push_str(line);
result.push('\n');
}
}
result.trim().to_string()
}
/// Extract simple lessons from tool call outcomes (no LLM needed).
fn extract_lesson_from_tools(
tool_calls: &[crate::openhuman::agent::hooks::ToolCallRecord],
@@ -762,6 +762,8 @@ impl Agent {
.add_section(Box::new(
crate::openhuman::learning::UserProfileSection::new(memory.clone()),
));
// NOTE: MemoryAccessSection is added after tool-filtering so we can
// gate it on retrieval-tool visibility — see below.
log::info!(
"[learning] prompt sections registered (user_reflections, learned_context, user_profile)"
);
@@ -928,6 +930,35 @@ impl Agent {
.map(|t| t.name().to_string())
.collect(),
};
// Phase 4 (#566): add the MemoryAccessSection bias instruction only
// when at least one retrieval tool is actually loaded AND survives
// filtering. We require both because:
// - the tool may be filtered out by the agent's scope config
// - the tool may not be registered at all on this agent (tool
// listing is build-time configurable)
// An empty `visible` set means "no filter" (wildcard / orchestrator
// path); in that case any registered retrieval tool is reachable.
if config.learning.enabled {
let recall_tools = ["memory_recall", "memory_search"];
let has_retrieval = recall_tools.iter().any(|name| {
let registered = tools.iter().any(|t| t.name() == *name)
|| delegation_tools.iter().any(|t| t.name() == *name);
let allowed_by_filter = visible.is_empty() || visible.contains(*name);
registered && allowed_by_filter
});
if has_retrieval {
prompt_builder = prompt_builder
.add_section(Box::new(crate::openhuman::learning::MemoryAccessSection));
log::debug!("[learning] memory_access prompt section registered");
} else {
log::debug!(
"[learning] skipping MemoryAccessSection — neither memory_recall nor \
memory_search is registered+visible for agent={agent_id}"
);
}
}
// De-duplicate: some synthesised tool names may collide with
// already-registered tools (unlikely for `delegate_*` names but
// cheap to guard against).
+74
View File
@@ -64,6 +64,80 @@ pub async fn start_channels(config: Config) -> Result<()> {
// channel dispatch calls `request_native_global("agent.run_turn", …)`
// for every inbound message.
crate::openhuman::agent::bus::register_agent_handlers();
// Phase 2 learning producers: email-signature subscriber reacts to
// DocumentCanonicalized events and emits Identity candidates into the buffer.
// The handle is intentionally leaked into a static so the subscription stays
// alive for the lifetime of the process (same pattern as TracingSubscriber).
{
use crate::core::event_bus::SubscriptionHandle;
use std::sync::OnceLock;
static EMAIL_SIG_HANDLE: OnceLock<Option<SubscriptionHandle>> = OnceLock::new();
EMAIL_SIG_HANDLE.get_or_init(|| {
crate::openhuman::learning::extract::signature::register_email_signature_subscriber()
});
}
// Phase 3 learning: register the event-driven rebuild trigger.
// The stability detector is wired up only when the global memory client is
// already initialised (it may not be in the channel runtime path — the
// client is initialised later in `start_channels`).
{
use crate::core::event_bus::SubscriptionHandle;
use std::sync::OnceLock;
static REBUILD_TRIGGER_HANDLE: OnceLock<Option<SubscriptionHandle>> = OnceLock::new();
REBUILD_TRIGGER_HANDLE.get_or_init(|| {
if let Some(client) = crate::openhuman::memory::global::client_if_ready() {
use crate::openhuman::learning::cache::FacetCache;
use crate::openhuman::learning::scheduler::register_event_trigger;
use crate::openhuman::learning::StabilityDetector;
use std::sync::Arc;
let cache = FacetCache::new(client.profile_conn());
let detector = Arc::new(StabilityDetector::new(cache));
// Also spawn the periodic rebuild loop (30-minute cadence).
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
// Leak the sender so the loop never receives a shutdown signal
// until the process exits. This matches the pattern used by
// other always-on background tasks.
Box::leak(Box::new(shutdown_tx));
crate::openhuman::learning::scheduler::spawn_rebuild_loop(
Arc::clone(&detector),
crate::openhuman::learning::scheduler::DEFAULT_REBUILD_INTERVAL,
shutdown_rx,
);
register_event_trigger(detector)
} else {
tracing::debug!("[learning::scheduler] memory client not ready at channel startup, skipping event-trigger registration");
None
}
});
}
// Phase 4 learning: register the ProfileMdRenderer subscriber.
// Subscribes to CacheRebuilt events and re-renders the five cache-derived
// PROFILE.md blocks (style, identity, tooling, vetoes, goals).
{
use crate::core::event_bus::SubscriptionHandle;
use std::sync::OnceLock;
static PROFILE_MD_RENDERER_HANDLE: OnceLock<Option<SubscriptionHandle>> = OnceLock::new();
PROFILE_MD_RENDERER_HANDLE.get_or_init(|| {
if let Some(client) = crate::openhuman::memory::global::client_if_ready() {
use crate::openhuman::learning::cache::FacetCache;
use crate::openhuman::learning::ProfileMdRenderer;
use std::sync::Arc;
let cache = Arc::new(FacetCache::new(client.profile_conn()));
let renderer =
Arc::new(ProfileMdRenderer::new(cache, config.workspace_dir.clone()));
ProfileMdRenderer::subscribe(renderer)
} else {
tracing::debug!(
"[learning::profile_md_renderer] memory client not ready at startup, \
skipping ProfileMdRenderer registration"
);
None
}
});
}
tracing::debug!("[event_bus] global singleton initialized in start_channels");
// Initialise the sub-agent definition registry from this workspace.
+27 -2
View File
@@ -18,7 +18,10 @@
//! RPC ops.
use super::ProviderUserProfile;
use crate::openhuman::memory::store::profile::{self, FacetType};
use crate::openhuman::learning::candidate::{
self as learning_candidate, CueFamily, EvidenceRef, FacetClass, LearningCandidate,
};
use crate::openhuman::memory::store::profile::{self, FacetState, FacetType, UserState};
use rusqlite::params;
use serde_json::Value;
use std::collections::BTreeMap;
@@ -170,6 +173,28 @@ pub fn persist_provider_profile(profile: &ProviderUserProfile) -> usize {
);
continue;
}
// Phase 3 (#566): also emit a LearningCandidate so the stability detector
// can score provider data alongside other evidence on the next rebuild.
// We use the `identity/` key prefix for provider identity fields.
if kind.is_matchable() {
let identity_key = format!("{}:{}", normalize_token(&toolkit), kind.as_str());
let candidate = LearningCandidate {
class: FacetClass::Identity,
key: identity_key,
value: value.clone(),
cue_family: CueFamily::Structural,
evidence: EvidenceRef::Provider {
toolkit: toolkit.clone(),
connection_id: identifier.clone(),
field: kind.as_str().to_string(),
},
initial_confidence: kind.confidence(),
observed_at: now,
};
learning_candidate::global().push(candidate);
}
written += 1;
}
@@ -178,7 +203,7 @@ pub fn persist_provider_profile(profile: &ProviderUserProfile) -> usize {
toolkit = %toolkit,
identifier = %identifier,
rows_written = written,
"[composio:profile] persisted identity rows"
"[composio:profile] persisted identity rows (+ emitted Identity candidates)"
);
}
written
+317 -93
View File
@@ -1,23 +1,27 @@
//! `PROFILE.md` markdown bridge — mirrors the per-toolkit identity
//! fragments we already persist into the `user_profile` facet table
//! into a managed block inside `{workspace_dir}/PROFILE.md` so the
//! agent prompt loader (`agent/prompts/mod.rs::UserFilesSection`)
//! picks them up on the next turn.
//! `PROFILE.md` markdown bridge — mirrors managed facet blocks into
//! `{workspace_dir}/PROFILE.md` so the agent prompt loader
//! (`agent/prompts/mod.rs::UserFilesSection`) picks them up on the next
//! turn.
//!
//! The block lives between the markers
//! ## Block convention
//!
//! Each managed section lives between a pair of HTML comment markers:
//!
//! ```md
//! <!-- openhuman:connected-accounts:start -->
//! ...
//! <!-- openhuman:connected-accounts:end -->
//! <!-- openhuman:<block_name>:start -->
//! ## <Section Heading>
//!
//! <body_markdown>
//!
//! <!-- openhuman:<block_name>:end -->
//! ```
//!
//! Anything outside the markers is left untouched, so a profile authored
//! by the LinkedIn onboarding pipeline or hand-edited by the user is
//! preserved across reconnects.
//! Anything outside the markers is left untouched, so user-authored prose
//! or hand-edited bullets are preserved across provider reconnects or
//! cache rebuilds.
//!
//! All operations are best-effort and log on failure rather than
//! propagating, matching the existing PII-discipline pattern in
//! All operations are best-effort — errors are logged rather than
//! propagated, matching the PII-discipline pattern used in
//! `on_connection_created`.
use super::ProviderUserProfile;
@@ -25,17 +29,31 @@ use std::fs;
use std::io;
use std::path::Path;
const BLOCK_START: &str = "<!-- openhuman:connected-accounts:start -->";
const BLOCK_END: &str = "<!-- openhuman:connected-accounts:end -->";
const SECTION_HEADING: &str = "## Connected Accounts";
// ── Legacy connected-accounts constants (kept for internal helpers) ───────────
const CA_BLOCK: &str = "connected-accounts";
const CA_HEADING: &str = "## Connected Accounts";
const FILE_HEADER: &str = "# User Profile\n";
/// All managed block names, in the order they are appended when a new
/// `PROFILE.md` is created.
pub const BLOCKS: &[&str] = &[
"connected-accounts", // written by provider path (merge_provider_into_profile_md)
"style",
"identity",
"tooling",
"vetoes",
"goals",
];
// ── Public API ────────────────────────────────────────────────────────────────
/// Upsert the per-toolkit bullet for `profile` inside the managed
/// Connected Accounts block of `{workspace_dir}/PROFILE.md`.
/// `connected-accounts` block of `{workspace_dir}/PROFILE.md`.
///
/// Creates the file with a `# User Profile` header if it does not
/// exist. Idempotent — re-connecting the same toolkit replaces the
/// existing bullet rather than duplicating it.
/// Creates the file with a `# User Profile` header if it does not exist.
/// Idempotent — re-connecting the same toolkit replaces the existing
/// bullet rather than duplicating it.
pub fn merge_provider_into_profile_md(
workspace_dir: &Path,
profile: &ProviderUserProfile,
@@ -45,9 +63,7 @@ pub fn merge_provider_into_profile_md(
return Ok(());
}
// Require a real connection_id so the bullet keys match what the
// disconnect path (`composio_delete_connection`) will look up. A
// synthetic "default" fallback would orphan bullets when the
// connection is removed.
// disconnect path (`composio_delete_connection`) will look up.
let identifier = profile
.connection_id
.as_deref()
@@ -64,9 +80,8 @@ pub fn merge_provider_into_profile_md(
}
};
let bullet = match render_bullet(&toolkit, &identifier, profile) {
let bullet = match render_provider_bullet(&toolkit, &identifier, profile) {
Some(b) => b,
// No non-empty fields — nothing worth writing.
None => return Ok(()),
};
@@ -80,7 +95,7 @@ pub fn merge_provider_into_profile_md(
Err(e) => return Err(e),
};
let updated = upsert_bullet(&existing, &toolkit, &identifier, &bullet);
let updated = upsert_provider_bullet(&existing, &toolkit, &identifier, &bullet);
fs::write(&path, updated)?;
tracing::debug!(
target_file = "PROFILE.md",
@@ -92,9 +107,8 @@ pub fn merge_provider_into_profile_md(
}
/// Remove the per-toolkit bullet for `(source, identifier)` from the
/// managed Connected Accounts block. If the block becomes empty as a
/// result, the whole block is dropped. Missing file or missing block
/// are no-ops.
/// managed Connected Accounts block. If the block becomes empty the whole
/// block is dropped. Missing file or missing block are no-ops.
pub fn remove_provider_from_profile_md(
workspace_dir: &Path,
source: &str,
@@ -111,7 +125,7 @@ pub fn remove_provider_from_profile_md(
if toolkit.is_empty() || identifier.is_empty() {
return Ok(());
}
let updated = remove_bullet(&existing, &toolkit, &identifier);
let updated = remove_provider_bullet(&existing, &toolkit, &identifier);
if updated != existing {
fs::write(&path, updated)?;
tracing::debug!(
@@ -124,11 +138,56 @@ pub fn remove_provider_from_profile_md(
Ok(())
}
// ── Internals ────────────────────────────────────────────────────────
/// Upsert a generic managed block.
///
/// * `block_name` — one of [`BLOCKS`] (e.g. `"style"`, `"identity"`).
/// * `section_heading` — heading rendered inside the block (e.g. `"## Style"`).
/// * `body_markdown` — pre-rendered content (bullets, prose). Must not
/// contain the block markers themselves.
///
/// Creates `PROFILE.md` if it does not exist. If the block is absent it is
/// appended at the end of the file. If the block exists its body is replaced
/// in-place — content outside the markers is left byte-for-byte untouched.
///
/// An empty `body_markdown` renders a `*(no entries yet)*` placeholder
/// instead of deleting the block; this preserves the block's position for the
/// next write.
///
/// Idempotent: calling with the same inputs twice produces the same file.
pub fn replace_managed_block(
workspace_dir: &Path,
block_name: &str,
section_heading: &str,
body_markdown: String,
) -> io::Result<()> {
let path = workspace_dir.join("PROFILE.md");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
/// Build the markdown bullet for one provider connection. Returns
/// `None` if the profile carries no usable fields.
fn render_bullet(toolkit: &str, identifier: &str, profile: &ProviderUserProfile) -> Option<String> {
let existing = match fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
let updated = upsert_block(&existing, block_name, section_heading, &body_markdown);
fs::write(&path, updated)?;
tracing::debug!(
block_name = %block_name,
"[composio:profile_md] replaced managed block '{}' in PROFILE.md",
block_name
);
Ok(())
}
// ── Connected-accounts internals ──────────────────────────────────────────────
fn render_provider_bullet(
toolkit: &str,
identifier: &str,
profile: &ProviderUserProfile,
) -> Option<String> {
let mut fields: Vec<String> = Vec::new();
if let Some(v) = profile.display_name.as_deref().map(sanitize) {
if !v.is_empty() {
@@ -153,8 +212,6 @@ fn render_bullet(toolkit: &str, identifier: &str, profile: &ProviderUserProfile)
if fields.is_empty() {
return None;
}
// Stable per-(toolkit,identifier) marker so we can locate this
// bullet on later upserts even if the rendered text changes.
let marker = bullet_marker(toolkit, identifier);
Some(format!(
"- {marker} **{title}** ({identifier}): {fields}",
@@ -168,10 +225,12 @@ fn bullet_marker(toolkit: &str, identifier: &str) -> String {
format!("<!-- acct:{toolkit}:{identifier} -->")
}
/// Insert or replace `bullet` inside the managed block.
fn upsert_bullet(existing: &str, toolkit: &str, identifier: &str, bullet: &str) -> String {
/// Insert or replace `bullet` inside the connected-accounts managed block.
fn upsert_provider_bullet(existing: &str, toolkit: &str, identifier: &str, bullet: &str) -> String {
let marker = bullet_marker(toolkit, identifier);
let (prefix, block_body, suffix) = split_block(existing);
let start_tag = block_start(CA_BLOCK);
let end_tag = block_end(CA_BLOCK);
let (prefix, block_body, suffix) = split_any_block(existing, &start_tag, &end_tag);
let mut lines: Vec<String> = block_body
.lines()
@@ -187,20 +246,21 @@ fn upsert_bullet(existing: &str, toolkit: &str, identifier: &str, bullet: &str)
bullets.sort();
let block = format!(
"{BLOCK_START}\n{SECTION_HEADING}\n\n{body}\n{BLOCK_END}",
"{start_tag}\n{CA_HEADING}\n\n{body}\n{end_tag}",
body = bullets.join("\n")
);
assemble(&prefix, &block, &suffix)
}
/// Remove the bullet matching `(toolkit, identifier)` from the managed
/// block. Drops the block entirely if no bullets remain.
fn remove_bullet(existing: &str, toolkit: &str, identifier: &str) -> String {
/// Remove the bullet matching `(toolkit, identifier)` from the connected-
/// accounts managed block. Drops the block entirely if no bullets remain.
fn remove_provider_bullet(existing: &str, toolkit: &str, identifier: &str) -> String {
let marker = bullet_marker(toolkit, identifier);
let (prefix, block_body, suffix) = split_block(existing);
let start_tag = block_start(CA_BLOCK);
let end_tag = block_end(CA_BLOCK);
let (prefix, block_body, suffix) = split_any_block(existing, &start_tag, &end_tag);
if block_body.is_empty() && prefix == existing {
// No managed block present.
return existing.to_string();
}
let bullets: Vec<String> = block_body
@@ -209,27 +269,70 @@ fn remove_bullet(existing: &str, toolkit: &str, identifier: &str) -> String {
.map(|l| l.to_string())
.collect();
if bullets.is_empty() {
// Drop the entire block.
return assemble(&prefix, "", &suffix);
}
let block = format!(
"{BLOCK_START}\n{SECTION_HEADING}\n\n{body}\n{BLOCK_END}",
"{start_tag}\n{CA_HEADING}\n\n{body}\n{end_tag}",
body = bullets.join("\n")
);
assemble(&prefix, &block, &suffix)
}
/// Split the file into `(prefix, block_body, suffix)` around the
/// managed block. Bytes outside the markers are returned verbatim so
/// the caller can preserve user-authored whitespace, indentation, and
/// trailing newlines exactly. If no block is present, `prefix` is the
/// full file and `block_body` / `suffix` are empty.
fn split_block(existing: &str) -> (String, String, String) {
if let (Some(start), Some(end)) = (existing.find(BLOCK_START), existing.find(BLOCK_END)) {
// ── Generic managed-block helpers ─────────────────────────────────────────────
/// Build the start marker for `block_name`.
pub fn block_start(block_name: &str) -> String {
format!("<!-- openhuman:{block_name}:start -->")
}
/// Build the end marker for `block_name`.
pub fn block_end(block_name: &str) -> String {
format!("<!-- openhuman:{block_name}:end -->")
}
/// Insert or replace a generic managed block in `existing`.
///
/// If the block is absent it is appended. If it exists its body (between the
/// markers) is replaced. Content outside the markers is returned unchanged.
fn upsert_block(
existing: &str,
block_name: &str,
section_heading: &str,
body_markdown: &str,
) -> String {
let start_tag = block_start(block_name);
let end_tag = block_end(block_name);
let body = if body_markdown.trim().is_empty() {
"*(no entries yet)*".to_string()
} else {
body_markdown.to_string()
};
let block = format!("{start_tag}\n{section_heading}\n\n{body}\n\n{end_tag}");
let (prefix, _old_body, suffix) = split_any_block(existing, &start_tag, &end_tag);
if prefix == existing {
// Block was absent — append.
assemble(existing, &block, "")
} else {
assemble(&prefix, &block, &suffix)
}
}
/// Split `existing` around the markers `[start_tag, end_tag]`.
///
/// Returns `(prefix, block_body, suffix)`. If no block is present,
/// `prefix` is the full string and `block_body` / `suffix` are empty.
/// `block_body` is the content *between* the markers (excluding the
/// markers themselves).
fn split_any_block(existing: &str, start_tag: &str, end_tag: &str) -> (String, String, String) {
if let (Some(start), Some(end)) = (existing.find(start_tag), existing.find(end_tag)) {
if end > start {
let prefix = existing[..start].to_string();
let body = existing[start + BLOCK_START.len()..end].to_string();
let suffix_start = end + BLOCK_END.len();
let body = existing[start + start_tag.len()..end].to_string();
let suffix_start = end + end_tag.len();
let suffix = existing[suffix_start..].to_string();
return (prefix, body, suffix);
}
@@ -237,25 +340,19 @@ fn split_block(existing: &str) -> (String, String, String) {
(existing.to_string(), String::new(), String::new())
}
/// Assemble `prefix + block + suffix`, preserving the user-authored
/// bytes in `prefix` and `suffix` verbatim. We only normalize the
/// newline separators *immediately adjacent* to the managed block —
/// the bytes we own — to keep one blank line on each boundary.
/// Assemble `prefix + block + suffix`, normalising the newlines immediately
/// adjacent to the managed block while leaving the user's bytes elsewhere
/// untouched.
fn assemble(prefix: &str, block: &str, suffix: &str) -> String {
if block.is_empty() {
// Removing the block entirely. Strip the newlines we previously
// added on each side of the block, but leave the rest of the
// user's content untouched.
// Removing the block entirely.
let p = prefix.trim_end_matches('\n');
let s = suffix.trim_start_matches('\n');
let mut out = String::with_capacity(p.len() + s.len() + 2);
out.push_str(p);
if !p.is_empty() {
// Keep one trailing newline on the prefix.
out.push('\n');
if !s.is_empty() {
// Plus a blank-line separator before whatever the user
// had after the block.
out.push('\n');
}
}
@@ -268,34 +365,35 @@ fn assemble(prefix: &str, block: &str, suffix: &str) -> String {
let mut out = String::new();
if prefix.trim().is_empty() {
// Empty / whitespace-only file → seed with a friendly header so
// the agent prompt loader has a sensible top of the file.
// Seed with a header on first creation.
out.push_str(FILE_HEADER);
out.push('\n');
} else {
// Preserve user prefix bytes verbatim, then ensure exactly one
// blank line before the block.
let p = prefix.trim_end_matches('\n');
out.push_str(p);
out.push_str("\n\n");
}
out.push_str(block);
// The block string we emit doesn't include a trailing newline.
if suffix.is_empty() {
out.push('\n');
} else {
// Drop any newlines we previously inserted between block and
// suffix; preserve the rest of the user's bytes.
let s = suffix.trim_start_matches('\n');
out.push_str("\n\n");
out.push_str(s);
if !out.ends_with('\n') {
if s.is_empty() {
// Suffix was only whitespace — end with single newline, no blank line.
out.push('\n');
} else {
out.push_str("\n\n");
out.push_str(s);
if !out.ends_with('\n') {
out.push('\n');
}
}
}
out
}
// ── Token / string helpers ────────────────────────────────────────────────────
fn normalize_token(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
@@ -322,11 +420,15 @@ fn sanitize(raw: &str) -> String {
replaced.split_whitespace().collect::<Vec<_>>().join(" ")
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
// ── merge_provider_into_profile_md (legacy API, unchanged) ───────────────
fn sample(toolkit: &str, conn: &str) -> ProviderUserProfile {
ProviderUserProfile {
toolkit: toolkit.into(),
@@ -346,12 +448,14 @@ mod tests {
merge_provider_into_profile_md(tmp.path(), &sample("gmail", "c-1")).unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(body.starts_with("# User Profile"), "body was:\n{body}");
assert!(body.contains(BLOCK_START));
assert!(body.contains(SECTION_HEADING));
let start = block_start(CA_BLOCK);
let end = block_end(CA_BLOCK);
assert!(body.contains(&start));
assert!(body.contains(CA_HEADING));
assert!(body.contains("**Gmail** (c-1):"));
assert!(body.contains("jane@example.com"));
assert!(body.contains("@janedoe"));
assert!(body.contains(BLOCK_END));
assert!(body.contains(&end));
}
#[test]
@@ -376,8 +480,10 @@ mod tests {
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(body.contains("acct:gmail:c-1"));
assert!(body.contains("acct:twitter:c-2"));
assert_eq!(body.matches(BLOCK_START).count(), 1);
assert_eq!(body.matches(BLOCK_END).count(), 1);
let start = block_start(CA_BLOCK);
let end = block_end(CA_BLOCK);
assert_eq!(body.matches(&start).count(), 1);
assert_eq!(body.matches(&end).count(), 1);
}
#[test]
@@ -431,8 +537,10 @@ mod tests {
merge_provider_into_profile_md(tmp.path(), &sample("gmail", "c-1")).unwrap();
remove_provider_from_profile_md(tmp.path(), "gmail", "c-1").unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(!body.contains(BLOCK_START), "block remained:\n{body}");
assert!(!body.contains(BLOCK_END));
let start = block_start(CA_BLOCK);
let end = block_end(CA_BLOCK);
assert!(!body.contains(&start), "block remained:\n{body}");
assert!(!body.contains(&end));
assert!(body.starts_with("# User Profile"));
}
@@ -457,8 +565,6 @@ mod tests {
extras: serde_json::Value::Null,
};
merge_provider_into_profile_md(tmp.path(), &p).unwrap();
// No file written — without a connection_id we'd orphan the
// bullet at disconnect time.
assert!(!tmp.path().join("PROFILE.md").exists());
}
@@ -466,23 +572,20 @@ mod tests {
fn preserves_indentation_and_blank_lines_around_block() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("PROFILE.md");
// User-authored content on both sides of where the block will
// land, with intentional blank lines and trailing whitespace.
let original = "# User Profile\n\n indented bio line\n\n## Notes\n- alpha\n- beta\n\n";
fs::write(&path, original).unwrap();
merge_provider_into_profile_md(tmp.path(), &sample("gmail", "c-1")).unwrap();
let body = fs::read_to_string(&path).unwrap();
// User content unchanged byte-for-byte.
assert!(body.contains(" indented bio line"));
assert!(body.contains("## Notes\n- alpha\n- beta"));
// Block landed somewhere.
assert!(body.contains(BLOCK_START) && body.contains(BLOCK_END));
// Now remove and verify the user content is still intact.
let start = block_start(CA_BLOCK);
let end = block_end(CA_BLOCK);
assert!(body.contains(&start) && body.contains(&end));
remove_provider_from_profile_md(tmp.path(), "gmail", "c-1").unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains(" indented bio line"));
assert!(after.contains("## Notes\n- alpha\n- beta"));
assert!(!after.contains(BLOCK_START));
assert!(!after.contains(&start));
}
#[test]
@@ -491,4 +594,125 @@ mod tests {
assert_eq!(sanitize("a | b"), "a / b");
assert_eq!(sanitize(" multi space "), "multi space");
}
// ── replace_managed_block ─────────────────────────────────────────────────
#[test]
fn replace_managed_block_creates_file_if_missing() {
let tmp = TempDir::new().unwrap();
replace_managed_block(
tmp.path(),
"style",
"## Style",
"- **verbosity**: terse".into(),
)
.unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(body.contains("# User Profile"), "missing header:\n{body}");
assert!(body.contains(&block_start("style")));
assert!(body.contains("## Style"));
assert!(body.contains("- **verbosity**: terse"));
assert!(body.contains(&block_end("style")));
}
#[test]
fn replace_managed_block_appends_block_when_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("PROFILE.md");
fs::write(&path, "# User Profile\n\nSome existing text.\n").unwrap();
replace_managed_block(
tmp.path(),
"identity",
"## Identity",
"- **name**: Alice".into(),
)
.unwrap();
let body = fs::read_to_string(&path).unwrap();
// Existing content preserved.
assert!(body.contains("Some existing text."));
// New block appended.
assert!(body.contains(&block_start("identity")));
assert!(body.contains("## Identity"));
assert!(body.contains("- **name**: Alice"));
}
#[test]
fn replace_managed_block_replaces_body_in_place() {
let tmp = TempDir::new().unwrap();
replace_managed_block(
tmp.path(),
"style",
"## Style",
"- **verbosity**: verbose".into(),
)
.unwrap();
replace_managed_block(
tmp.path(),
"style",
"## Style",
"- **verbosity**: terse".into(),
)
.unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(body.contains("terse"));
assert!(!body.contains("verbose"));
// Only one start marker.
assert_eq!(body.matches(&block_start("style")).count(), 1);
}
#[test]
fn replace_managed_block_preserves_other_blocks_and_user_text() {
let tmp = TempDir::new().unwrap();
// Write two blocks.
replace_managed_block(
tmp.path(),
"style",
"## Style",
"- **verbosity**: terse".into(),
)
.unwrap();
replace_managed_block(
tmp.path(),
"identity",
"## Identity",
"- **name**: Bob".into(),
)
.unwrap();
// Update only style.
replace_managed_block(
tmp.path(),
"style",
"## Style",
"- **verbosity**: verbose".into(),
)
.unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
// Identity block untouched.
assert!(body.contains("- **name**: Bob"));
// Style updated.
assert!(body.contains("verbose"));
assert!(!body.contains("terse"));
}
#[test]
fn replace_managed_block_empty_body_renders_placeholder() {
let tmp = TempDir::new().unwrap();
replace_managed_block(tmp.path(), "goals", "## Goals", String::new()).unwrap();
let body = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(body.contains("*(no entries yet)*"));
// Block markers still present.
assert!(body.contains(&block_start("goals")));
assert!(body.contains(&block_end("goals")));
}
#[test]
fn replace_managed_block_idempotent_on_repeat_invocation() {
let tmp = TempDir::new().unwrap();
let content = "- **verbosity**: terse".to_string();
replace_managed_block(tmp.path(), "style", "## Style", content.clone()).unwrap();
let body1 = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
replace_managed_block(tmp.path(), "style", "## Style", content).unwrap();
let body2 = fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert_eq!(body1, body2, "second write should be idempotent");
}
}
+27
View File
@@ -46,6 +46,30 @@ pub struct LearningConfig {
/// Minimum tool calls in a turn to trigger reflection. Default: 1.
#[serde(default = "default_min_turn_complexity")]
pub min_turn_complexity: usize,
/// Pipe agent chat turns into the memory tree as `source="conversations:agent"`.
///
/// When enabled, [`ArchivistHook`] calls `tree::ingest::ingest_chat` with a
/// two-message [`ChatBatch`] (user + assistant) after every completed turn.
/// Tool-call JSON is stripped from the assistant message before ingest —
/// only the prose response reaches the tree.
///
/// Default: true. Disable to stop agent chat from flowing into the tree
/// without affecting the episodic-log write path.
#[serde(default = "default_true")]
pub chat_to_tree_enabled: bool,
/// Enable the stability detector rebuild cycle. Default: true.
#[serde(default = "default_true")]
pub stability_detector_enabled: bool,
/// How often the periodic rebuild loop runs in seconds. Default: 1800 (30 minutes).
#[serde(default = "default_rebuild_interval_secs")]
pub rebuild_interval_secs: u64,
}
fn default_rebuild_interval_secs() -> u64 {
1800
}
fn default_true() -> bool {
@@ -70,6 +94,9 @@ impl Default for LearningConfig {
reflection_source: ReflectionSource::default(),
max_reflections_per_session: default_max_reflections(),
min_turn_complexity: default_min_turn_complexity(),
chat_to_tree_enabled: default_true(),
stability_detector_enabled: default_true(),
rebuild_interval_secs: default_rebuild_interval_secs(),
}
}
}
+126
View File
@@ -0,0 +1,126 @@
//! `FacetCache` — thin wrapper over `user_profile_facets` for Phase 3.
//!
//! Provides typed read/write access to the facet table with class-aware helpers.
//! The stability detector uses this to persist the result of each rebuild cycle.
//! Prompt sections use [`FacetCache::list_active`] to read the ambient cache.
use parking_lot::Mutex;
use rusqlite::Connection;
use std::sync::Arc;
use crate::openhuman::learning::candidate::FacetClass;
use crate::openhuman::memory::store::profile::{self, FacetState, ProfileFacet, UserState};
/// Thin wrapper around the `user_profile` table.
///
/// All methods delegate to the standalone helpers in
/// `memory::store::unified::profile`. This type exists so callers
/// (stability detector, prompt sections, RPCs) share a single typed
/// entry-point that can be constructed from any `Arc<Mutex<Connection>>`.
pub struct FacetCache {
conn: Arc<Mutex<Connection>>,
}
impl FacetCache {
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
Self { conn }
}
/// List all facets with `state = 'active'`, ordered by stability descending.
pub fn list_active(&self) -> anyhow::Result<Vec<ProfileFacet>> {
profile::profile_select_active(&self.conn)
}
/// List all facets (all states), ordered by stability descending.
pub fn list_all(&self) -> anyhow::Result<Vec<ProfileFacet>> {
profile::profile_select_all(&self.conn)
}
/// List active facets belonging to a specific class.
///
/// Class is determined by the `key` prefix before the first `/`.
pub fn list_by_class(&self, class: FacetClass) -> anyhow::Result<Vec<ProfileFacet>> {
let prefix = format!("{}/", class_prefix(class));
let all = self.list_active()?;
Ok(all
.into_iter()
.filter(|f| f.key.starts_with(&prefix))
.collect())
}
/// Fetch a single facet by its full key (e.g. `"style/verbosity"`).
pub fn get(&self, key: &str) -> anyhow::Result<Option<ProfileFacet>> {
profile::profile_get_by_key(&self.conn, key)
}
/// Upsert a fully-formed facet row (rebuild path).
pub fn upsert(&self, facet: &ProfileFacet) -> anyhow::Result<()> {
profile::profile_upsert_full(&self.conn, facet)
}
/// Override the `user_state` of a facet.
///
/// Returns `Ok(true)` if a row was found and updated.
pub fn set_user_state(&self, key: &str, user_state: UserState) -> anyhow::Result<bool> {
profile::profile_set_user_state(&self.conn, key, user_state)
}
/// Delete a facet by key. Returns `true` if a row was removed.
pub fn delete(&self, key: &str) -> anyhow::Result<bool> {
profile::profile_delete_by_key(&self.conn, key)
}
/// Delete all `Dropped`-state facets whose stability is below `threshold`.
///
/// Pinned facets are never deleted. Returns the number of rows removed.
pub fn drop_below_threshold(&self, threshold: f64) -> anyhow::Result<usize> {
profile::profile_delete_below_threshold(&self.conn, threshold)
}
}
// ── Class ↔ key utilities ─────────────────────────────────────────────────────
/// Extract the [`FacetClass`] from a full key string (e.g. `"style/verbosity"` → `Style`).
///
/// Returns `None` for keys that don't have a recognised class prefix.
pub fn class_from_key(key: &str) -> Option<FacetClass> {
let prefix = key.split('/').next()?;
match prefix {
"style" => Some(FacetClass::Style),
"identity" => Some(FacetClass::Identity),
"tooling" => Some(FacetClass::Tooling),
"veto" => Some(FacetClass::Veto),
"goal" => Some(FacetClass::Goal),
"channel" => Some(FacetClass::Channel),
_ => None,
}
}
/// Build a full key from a class and a suffix (e.g. `(Style, "verbosity")` → `"style/verbosity"`).
pub fn key_with_class(class: FacetClass, suffix: &str) -> String {
format!("{}/{suffix}", class_prefix(class))
}
/// Return the canonical key prefix for a [`FacetClass`].
pub fn class_prefix(class: FacetClass) -> &'static str {
match class {
FacetClass::Style => "style",
FacetClass::Identity => "identity",
FacetClass::Tooling => "tooling",
FacetClass::Veto => "veto",
FacetClass::Goal => "goal",
FacetClass::Channel => "channel",
}
}
// ── Facet state enum re-export (convenience for callers of this module) ───────
pub use crate::openhuman::memory::store::profile::{
FacetState as CacheFacetState, UserState as CacheUserState,
};
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
#[path = "cache_tests.rs"]
mod tests;
+250
View File
@@ -0,0 +1,250 @@
//! Tests for `learning::cache::FacetCache`.
use parking_lot::Mutex;
use rusqlite::Connection;
use std::sync::Arc;
use super::*;
use crate::openhuman::learning::candidate::{EvidenceRef, FacetClass};
use crate::openhuman::memory::store::profile::{
FacetState, FacetType, ProfileFacet, UserState, PROFILE_INIT_SQL,
};
fn make_cache() -> FacetCache {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(PROFILE_INIT_SQL).unwrap();
FacetCache::new(Arc::new(Mutex::new(conn)))
}
fn stub_facet(id: &str, key: &str, value: &str, state: FacetState, stability: f64) -> ProfileFacet {
ProfileFacet {
facet_id: id.into(),
facet_type: FacetType::Preference,
key: key.into(),
value: value.into(),
confidence: 0.8,
evidence_count: 2,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1200.0,
state,
stability,
user_state: UserState::Auto,
evidence_refs: vec![],
class: None,
cue_families: None,
}
}
// ── upsert_then_list_active ───────────────────────────────────────────────────
#[test]
fn upsert_then_list_active() {
let cache = make_cache();
cache
.upsert(&stub_facet(
"f1",
"style/verbosity",
"terse",
FacetState::Active,
1.8,
))
.unwrap();
cache
.upsert(&stub_facet(
"f2",
"style/tone",
"formal",
FacetState::Provisional,
0.8,
))
.unwrap();
let active = cache.list_active().unwrap();
assert_eq!(active.len(), 1, "only Active state should be listed");
assert_eq!(active[0].key, "style/verbosity");
}
// ── class_from_key_parses_known_classes ───────────────────────────────────────
#[test]
fn class_from_key_parses_known_classes() {
assert_eq!(class_from_key("style/verbosity"), Some(FacetClass::Style));
assert_eq!(class_from_key("identity/name"), Some(FacetClass::Identity));
assert_eq!(
class_from_key("tooling/package_manager"),
Some(FacetClass::Tooling)
);
assert_eq!(
class_from_key("veto/no_sports_updates"),
Some(FacetClass::Veto)
);
assert_eq!(class_from_key("goal/learn_rust"), Some(FacetClass::Goal));
assert_eq!(class_from_key("channel/slack"), Some(FacetClass::Channel));
assert_eq!(class_from_key("unknown/foo"), None);
assert_eq!(class_from_key("no_slash"), None);
}
// ── set_user_state_pinned_persists ────────────────────────────────────────────
#[test]
fn set_user_state_pinned_persists() {
let cache = make_cache();
cache
.upsert(&stub_facet(
"f-pin",
"identity/name",
"Alice",
FacetState::Active,
2.0,
))
.unwrap();
let updated = cache
.set_user_state("identity/name", UserState::Pinned)
.unwrap();
assert!(updated, "row should exist and be updated");
let f = cache.get("identity/name").unwrap().unwrap();
assert_eq!(f.user_state, UserState::Pinned);
}
// ── drop_below_threshold_removes_facets ───────────────────────────────────────
#[test]
fn drop_below_threshold_removes_facets() {
let cache = make_cache();
cache
.upsert(&stub_facet(
"f-low",
"style/dropped_one",
"x",
FacetState::Dropped,
0.1,
))
.unwrap();
cache
.upsert(&stub_facet(
"f-keep",
"style/active_one",
"y",
FacetState::Active,
0.1, // low stability but Active state — should NOT be deleted
))
.unwrap();
cache
.upsert(&stub_facet(
"f-pinned-drop",
"style/pinned_one",
"z",
FacetState::Dropped,
0.1,
))
.and_then(|_| cache.set_user_state("style/pinned_one", UserState::Pinned))
.unwrap();
let removed = cache.drop_below_threshold(0.3).unwrap();
assert_eq!(
removed, 1,
"only the non-pinned Dropped row should be removed"
);
// Active and Pinned rows survive.
let all = cache.list_all().unwrap();
assert_eq!(all.len(), 2);
}
// ── list_by_class_filters_correctly ───────────────────────────────────────────
#[test]
fn list_by_class_filters_correctly() {
let cache = make_cache();
for (id, key, val) in [
("f-s1", "style/verbosity", "terse"),
("f-s2", "style/tone", "formal"),
("f-i1", "identity/name", "Alice"),
] {
cache
.upsert(&stub_facet(id, key, val, FacetState::Active, 1.6))
.unwrap();
}
let style = cache.list_by_class(FacetClass::Style).unwrap();
assert_eq!(style.len(), 2);
assert!(style.iter().all(|f| f.key.starts_with("style/")));
let identity = cache.list_by_class(FacetClass::Identity).unwrap();
assert_eq!(identity.len(), 1);
assert_eq!(identity[0].key, "identity/name");
let tooling = cache.list_by_class(FacetClass::Tooling).unwrap();
assert!(tooling.is_empty());
}
// ── key_with_class helper ─────────────────────────────────────────────────────
#[test]
fn key_with_class_produces_prefixed_key() {
assert_eq!(
key_with_class(FacetClass::Style, "verbosity"),
"style/verbosity"
);
assert_eq!(
key_with_class(FacetClass::Tooling, "package_manager"),
"tooling/package_manager"
);
}
// ── Evidence refs round-trip ──────────────────────────────────────────────────
#[test]
fn evidence_refs_survive_upsert_round_trip() {
let cache = make_cache();
let mut f = stub_facet("f-ev", "identity/email", "a@b.com", FacetState::Active, 2.0);
f.evidence_refs = vec![
EvidenceRef::Provider {
toolkit: "gmail".into(),
connection_id: "c-1".into(),
field: "email".into(),
},
EvidenceRef::Episodic { episodic_id: 7 },
];
cache.upsert(&f).unwrap();
let loaded = cache.get("identity/email").unwrap().unwrap();
assert_eq!(loaded.evidence_refs.len(), 2);
assert_eq!(
loaded.evidence_refs[0],
EvidenceRef::Provider {
toolkit: "gmail".into(),
connection_id: "c-1".into(),
field: "email".into(),
}
);
}
// ── delete helper ─────────────────────────────────────────────────────────────
#[test]
fn delete_removes_facet_by_key() {
let cache = make_cache();
cache
.upsert(&stub_facet(
"f-del",
"goal/learn_rust",
"learn Rust",
FacetState::Active,
1.5,
))
.unwrap();
let deleted = cache.delete("goal/learn_rust").unwrap();
assert!(deleted);
let loaded = cache.get("goal/learn_rust").unwrap();
assert!(loaded.is_none());
}
+380
View File
@@ -0,0 +1,380 @@
//! Learning candidate buffer — Phase 1 of issue #566.
//!
//! Defines the taxonomy types ([`FacetClass`], [`CueFamily`], [`EvidenceRef`]),
//! the unit-of-work [`LearningCandidate`], and a thread-safe ring-buffer
//! [`Buffer`] that collects candidates emitted by producers (Phase 2) before
//! they are consumed by the stability detector (Phase 3).
//!
//! The buffer is bounded: when full it evicts the oldest entry (FIFO overflow).
//! A global singleton is exposed via [`global()`]; individual tests may
//! construct their own [`Buffer`] with `Buffer::new(capacity)`.
use std::collections::VecDeque;
use std::sync::OnceLock;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
// ── Taxonomy ────────────────────────────────────────────────────────────────
/// Six-class taxonomy of what the cache can hold.
///
/// Keys are stored with a class prefix, e.g. `style/verbosity` or
/// `tooling/package_manager`. The class determines the half-life and
/// class budget used by the stability detector (Phase 3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FacetClass {
/// Communication style preferences — verbosity, formality, code format.
Style,
/// Stable biographical facts — timezone, name, language, role.
Identity,
/// Developer toolchain preferences — package manager, editor, OS, language.
Tooling,
/// Hard user vetoes — things the user has explicitly rejected or forbidden.
Veto,
/// Active user goals or ongoing projects.
Goal,
/// Preferred communication channel or platform.
Channel,
}
/// How a candidate signal was produced — determines the weight multiplier
/// applied in the stability formula.
///
/// Higher-weight families contribute more strongly per evidence item.
/// The weights here are the canonical values from the Phase 1 plan:
/// `Explicit=1.0`, `Structural=0.9`, `Behavioral=0.7`, `Recurrence=0.6`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CueFamily {
/// Direct declaration of intent by the user (highest weight — 1.0).
///
/// Examples: "I prefer pnpm", "my timezone is PST", "always use terse replies".
Explicit,
/// Inferred from structured file or provider metadata (weight 0.9).
///
/// Examples: `package.json#packageManager`, Gmail display name, Slack workspace.
Structural,
/// Inferred by heuristics or LLM from observed behaviour (weight 0.7).
///
/// Examples: rolling edit-window ratio, correction-repeat signal, reflection hook output.
Behavioral,
/// Materialized from recurrence statistics in the memory tree (weight 0.6).
///
/// Examples: tree-topic hotness, source_weight per channel.
Recurrence,
}
impl CueFamily {
/// Weight multiplier for this cue family in the stability formula.
///
/// Phase 1 canonical values (matches the plan):
/// `Explicit=1.0`, `Structural=0.9`, `Behavioral=0.7`, `Recurrence=0.6`.
pub fn weight(self) -> f64 {
match self {
CueFamily::Explicit => 1.0,
CueFamily::Structural => 0.9,
CueFamily::Behavioral => 0.7,
CueFamily::Recurrence => 0.6,
}
}
}
// ── Evidence reference ───────────────────────────────────────────────────────
/// A typed pointer back into the memory substrate from which a candidate was
/// derived. Used for provenance tracking, citation, and the `evidence_ids`
/// column in `user_profile_facets` (Phase 3+).
///
/// Serialised with a `"type"` discriminator in snake_case so the JSON is
/// human-readable: `{"type":"episodic","episodic_id":42}`.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum EvidenceRef {
/// A single row in `episodic_log`.
Episodic { episodic_id: i64 },
/// A contiguous window of rows in `episodic_log`.
EpisodicWindow { from_id: i64, to_id: i64 },
/// A row in the tree-source summary table.
SourceSummary { summary_id: String },
/// A node in `tree_topic`.
TreeTopic { topic_id: String },
/// A chunk in `vector_chunks` associated with a document source.
DocumentChunk { source_id: String, chunk_id: String },
/// A specific message in an email source.
EmailMessage {
source_id: String,
message_id: String,
},
/// A field value from a connected provider (Composio toolkit).
Provider {
toolkit: String,
connection_id: String,
field: String,
},
/// A tool call record within an episodic entry.
ToolCall { tool_name: String, episodic_id: i64 },
/// A per-window weight from `tree_source`.
TreeSourceWeight { window_label: String },
}
// ── Learning candidate ───────────────────────────────────────────────────────
/// A single unit of learning evidence emitted by a producer and queued in the
/// [`Buffer`].
///
/// Each candidate asserts a specific `(class, key, value)` triple alongside
/// the evidence that backs it. The stability detector (Phase 3) aggregates
/// competing candidates for the same `(class, key)` pair and resolves them
/// into a single cache entry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LearningCandidate {
/// Which facet class this evidence touches.
pub class: FacetClass,
/// Canonical slug key within the class, e.g. `"verbosity"`, `"package_manager"`.
///
/// Convention: `snake_case`, lowercase, no class prefix (the class carries that).
pub key: String,
/// Canonical value string, e.g. `"terse"`, `"pnpm"`, `"UTC+5:30"`.
pub value: String,
/// How this candidate was produced.
pub cue_family: CueFamily,
/// Pointer to the backing evidence in the memory substrate.
pub evidence: EvidenceRef,
/// Source-provided confidence hint, `0.0..=1.0`.
///
/// This is an initial hint; the stability detector will reweight it using
/// the cue-family weight and recency decay.
pub initial_confidence: f64,
/// When this candidate was observed, as seconds since the Unix epoch.
pub observed_at: f64,
}
// ── Buffer ───────────────────────────────────────────────────────────────────
/// Thread-safe, bounded ring-buffer of [`LearningCandidate`] items.
///
/// Backed by a `parking_lot::Mutex<VecDeque<LearningCandidate>>`. When full
/// the oldest entry is evicted to make room (FIFO overflow). This keeps
/// memory bounded and naturally prioritises recent evidence.
///
/// The global singleton has a default capacity of 1024. Tests should
/// construct their own buffer via [`Buffer::new`].
pub struct Buffer {
inner: Mutex<VecDeque<LearningCandidate>>,
capacity: usize,
}
impl Buffer {
/// Create a new buffer with the given capacity.
///
/// `capacity` must be ≥ 1. A capacity of zero would make every `push`
/// a no-op; callers should use a non-zero value.
pub fn new(capacity: usize) -> Self {
let cap = capacity.max(1);
Self {
inner: Mutex::new(VecDeque::with_capacity(cap)),
capacity: cap,
}
}
/// Push a candidate onto the buffer.
///
/// If the buffer is already at capacity, the oldest entry is evicted first
/// (FIFO overflow). This ensures the buffer always reflects the most recent
/// evidence.
pub fn push(&self, candidate: LearningCandidate) {
let mut guard = self.inner.lock();
if guard.len() >= self.capacity {
guard.pop_front(); // evict oldest
}
guard.push_back(candidate);
}
/// Drain all candidates from the buffer and return them in FIFO order.
///
/// After this call the buffer is empty.
pub fn drain(&self) -> Vec<LearningCandidate> {
let mut guard = self.inner.lock();
guard.drain(..).collect()
}
/// Clone all candidates without removing them.
///
/// Useful for inspection or debugging.
pub fn peek(&self) -> Vec<LearningCandidate> {
let guard = self.inner.lock();
guard.iter().cloned().collect()
}
/// Current number of candidates in the buffer.
pub fn len(&self) -> usize {
self.inner.lock().len()
}
/// Returns `true` when the buffer holds no candidates.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Maximum number of candidates the buffer will hold.
pub fn capacity(&self) -> usize {
self.capacity
}
}
// ── Global singleton ─────────────────────────────────────────────────────────
static GLOBAL_BUFFER: OnceLock<Buffer> = OnceLock::new();
/// Return the global [`Buffer`] singleton.
///
/// Initialised on first call with a default capacity of 1024. All producers
/// push into this buffer; the stability detector drains it.
pub fn global() -> &'static Buffer {
GLOBAL_BUFFER.get_or_init(|| Buffer::new(1024))
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
fn make_candidate(value: &str) -> LearningCandidate {
LearningCandidate {
class: FacetClass::Style,
key: "verbosity".into(),
value: value.into(),
cue_family: CueFamily::Explicit,
evidence: EvidenceRef::Episodic { episodic_id: 1 },
initial_confidence: 0.8,
observed_at: now_secs(),
}
}
#[test]
fn push_then_drain_preserves_fifo_order() {
let buf = Buffer::new(10);
buf.push(make_candidate("a"));
buf.push(make_candidate("b"));
buf.push(make_candidate("c"));
let drained = buf.drain();
assert_eq!(drained.len(), 3);
assert_eq!(drained[0].value, "a");
assert_eq!(drained[1].value, "b");
assert_eq!(drained[2].value, "c");
}
#[test]
fn drain_empties_the_buffer() {
let buf = Buffer::new(10);
buf.push(make_candidate("x"));
buf.push(make_candidate("y"));
assert_eq!(buf.len(), 2);
let _ = buf.drain();
assert_eq!(buf.len(), 0);
assert!(buf.is_empty());
}
#[test]
fn bounded_capacity_evicts_oldest() {
let buf = Buffer::new(3);
buf.push(make_candidate("first"));
buf.push(make_candidate("second"));
buf.push(make_candidate("third"));
// Buffer is full — next push evicts "first"
buf.push(make_candidate("fourth"));
assert_eq!(buf.len(), 3);
let items = buf.drain();
assert_eq!(items[0].value, "second");
assert_eq!(items[1].value, "third");
assert_eq!(items[2].value, "fourth");
}
#[test]
fn peek_does_not_remove() {
let buf = Buffer::new(10);
buf.push(make_candidate("p"));
buf.push(make_candidate("q"));
let peeked = buf.peek();
assert_eq!(peeked.len(), 2);
// Buffer still holds the items
assert_eq!(buf.len(), 2);
let drained = buf.drain();
assert_eq!(drained[0].value, "p");
assert_eq!(drained[1].value, "q");
}
#[test]
fn cue_family_weight_values() {
assert_eq!(CueFamily::Explicit.weight(), 1.0);
assert_eq!(CueFamily::Structural.weight(), 0.9);
assert_eq!(CueFamily::Behavioral.weight(), 0.7);
assert_eq!(CueFamily::Recurrence.weight(), 0.6);
}
#[test]
fn roundtrip_serde_evidence_ref() {
let cases: Vec<EvidenceRef> = vec![
EvidenceRef::Episodic { episodic_id: 42 },
EvidenceRef::EpisodicWindow {
from_id: 10,
to_id: 20,
},
EvidenceRef::SourceSummary {
summary_id: "sum-abc".into(),
},
EvidenceRef::TreeTopic {
topic_id: "topic-xyz".into(),
},
EvidenceRef::DocumentChunk {
source_id: "notion:page1".into(),
chunk_id: "chunk-001".into(),
},
EvidenceRef::EmailMessage {
source_id: "gmail:user@example.com".into(),
message_id: "<abc123@mail.gmail.com>".into(),
},
EvidenceRef::Provider {
toolkit: "gmail".into(),
connection_id: "conn-1".into(),
field: "display_name".into(),
},
EvidenceRef::ToolCall {
tool_name: "write_file".into(),
episodic_id: 99,
},
EvidenceRef::TreeSourceWeight {
window_label: "2026-W18".into(),
},
];
for ev in &cases {
let json = serde_json::to_string(ev).expect("serialize failed");
let back: EvidenceRef = serde_json::from_str(&json).expect("deserialize failed");
assert_eq!(ev, &back, "round-trip failed for variant: {json}");
}
}
#[test]
fn global_returns_same_instance_across_calls() {
let a = global() as *const Buffer;
let b = global() as *const Buffer;
assert_eq!(a, b, "global() must return the same static instance");
}
}
@@ -0,0 +1,614 @@
//! Deterministic heuristic detectors for Style and Veto candidates.
//!
//! Three detectors feed [`crate::openhuman::learning::candidate::global()`]:
//!
//! - **`LengthRatioDetector`** — emits `Style/verbosity` when the rolling
//! ratio of user-to-agent message lengths shifts significantly over ≥ 30 turns.
//! - **`EditWindowDetector`** — emits `Style/*` candidates when the user sends
//! a correction within 30 s of the previous agent reply.
//! - **`CorrectionRepeatDetector`** — promotes repeated (≥ 3×) correction cues to
//! `Veto/*` candidates.
//!
//! All three detectors advance via a single call to [`record_turn`], which is
//! intended to be called from [`crate::openhuman::learning::ReflectionHook`] or a
//! sibling post-turn hook.
//!
//! ## State lifetime
//!
//! State is per-session and bounded to 100 turns per session. A global `RwLock`-
//! guarded map keeps per-session [`RollingState`]. Sessions that exceed
//! `MAX_SESSION_TURNS` have their oldest turn evicted (FIFO) to stay bounded.
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use parking_lot::RwLock;
use crate::openhuman::learning::candidate::{
self, CueFamily, EvidenceRef, FacetClass, LearningCandidate,
};
// ── Constants ────────────────────────────────────────────────────────────────
/// Per-session turn capacity before FIFO eviction.
const MAX_SESSION_TURNS: usize = 100;
/// Minimum number of turns before the length-ratio detector fires.
const LENGTH_RATIO_MIN_TURNS: usize = 30;
/// Number of turns in each of the two comparison windows.
const LENGTH_RATIO_WINDOW: usize = 15;
/// User-to-agent message length ratio below which we classify as "compressed".
const RATIO_COMPRESSED: f64 = 0.3;
/// User-to-agent message length ratio above which we classify as "balanced".
const RATIO_BALANCED: f64 = 0.8;
/// Ratio of the "earlier" window required to cross before we treat the shift as meaningful.
const RATIO_EARLIER_THRESHOLD: f64 = 0.5;
/// Edit-window threshold: correction sent within 30 s of agent reply is "quick".
const EDIT_WINDOW_SECS: f64 = 30.0;
/// Cooldown for EditWindowDetector per (key, value): 5 minutes.
const EDIT_COOLDOWN_SECS: f64 = 300.0;
/// Number of repeated corrections before promoting to Veto.
const VETO_PROMOTION_THRESHOLD: usize = 3;
// ── Per-turn state ────────────────────────────────────────────────────────────
/// A single turn entry in the rolling window.
#[derive(Clone, Debug)]
pub struct TurnEntry {
pub turn_id: String,
pub user_msg_len: usize,
pub agent_msg_len: usize,
/// Wall-clock seconds (epoch) when the user message arrived.
pub user_timestamp: f64,
/// Wall-clock seconds (epoch) when the agent finished replying.
pub agent_timestamp: f64,
}
/// Per-session state for all three detectors.
#[derive(Default)]
pub struct RollingState {
pub turns: VecDeque<TurnEntry>,
/// Last emission per (key, value) to enforce cooldown / dedupe for length-ratio.
pub length_ratio_emitted: HashSet<(String, String)>,
/// Per (key, value) last emission timestamp for edit-window cooldown.
pub edit_cooldown: HashMap<(String, String), f64>,
/// Per correction-cue counter across the session.
pub correction_counts: HashMap<String, usize>,
/// Cues that have already been promoted to Veto (prevent double-emit).
pub veto_promoted: HashSet<String>,
}
// ── Global state map ─────────────────────────────────────────────────────────
static SESSION_STATE: OnceLock<RwLock<HashMap<String, RollingState>>> = OnceLock::new();
fn session_state() -> &'static RwLock<HashMap<String, RollingState>> {
SESSION_STATE.get_or_init(|| RwLock::new(HashMap::new()))
}
// ── Public entry point ────────────────────────────────────────────────────────
/// Advance all three detectors for a completed turn and push any emitted
/// candidates to [`candidate::global()`].
///
/// # Arguments
///
/// * `session_id` — stable session identifier used as the state map key.
/// * `turn_id` — stable identifier for the current turn (e.g. an episodic id
/// encoded as a string).
/// * `episodic_id` — numeric episodic log id used to build `EvidenceRef`.
/// * `user_message` — user's message text for edit-window scanning.
/// * `user_msg_len` — byte length of the user message.
/// * `agent_msg_len` — byte length of the assistant response.
/// * `user_timestamp` — epoch seconds when the user message arrived.
/// * `agent_timestamp` — epoch seconds when the agent replied.
/// * `prev_agent_at` — epoch seconds of the **previous** agent reply, used by
/// the edit-window detector to check whether the user responded within
/// `EDIT_WINDOW_SECS`. Pass `None` on the first turn of a session.
#[allow(clippy::too_many_arguments)]
pub fn record_turn(
session_id: &str,
turn_id: &str,
episodic_id: i64,
user_message: &str,
user_msg_len: usize,
agent_msg_len: usize,
user_timestamp: f64,
agent_timestamp: f64,
prev_agent_at: Option<f64>,
) {
let entry = TurnEntry {
turn_id: turn_id.to_string(),
user_msg_len,
agent_msg_len,
user_timestamp,
agent_timestamp,
};
let mut candidates: Vec<LearningCandidate> = Vec::new();
{
let mut map = session_state().write();
let state = map.entry(session_id.to_string()).or_default();
// FIFO eviction.
if state.turns.len() >= MAX_SESSION_TURNS {
state.turns.pop_front();
}
state.turns.push_back(entry.clone());
// ── A. Length-ratio detector ──────────────────────────────────────
if state.turns.len() >= LENGTH_RATIO_MIN_TURNS {
let turns_slice: Vec<&TurnEntry> = state.turns.iter().collect();
let n = turns_slice.len();
let recent: &[&TurnEntry] = &turns_slice[n - LENGTH_RATIO_WINDOW..];
let earlier: &[&TurnEntry] = &turns_slice
[n - LENGTH_RATIO_MIN_TURNS..n - LENGTH_RATIO_MIN_TURNS + LENGTH_RATIO_WINDOW];
let recent_ratio = mean_ratio(recent);
let earlier_ratio = mean_ratio(earlier);
// Compressed: user messages shrunk relative to earlier.
if recent_ratio < RATIO_COMPRESSED
&& earlier_ratio > RATIO_EARLIER_THRESHOLD
&& !state
.length_ratio_emitted
.contains(&("verbosity".to_string(), "compressed".to_string()))
{
let first_id = state.turns.front().map(|t| t.turn_id.clone());
let last_id = state.turns.back().map(|t| t.turn_id.clone());
let from_id = first_id
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(episodic_id);
let to_id = last_id
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(episodic_id);
tracing::debug!(
"[learning::extract::heuristics] length_ratio compressed session={} \
recent_ratio={:.2} earlier_ratio={:.2}",
session_id,
recent_ratio,
earlier_ratio
);
candidates.push(LearningCandidate {
class: FacetClass::Style,
key: "verbosity".to_string(),
value: "compressed".to_string(),
cue_family: CueFamily::Behavioral,
evidence: EvidenceRef::EpisodicWindow { from_id, to_id },
initial_confidence: 0.65,
observed_at: now_secs(),
});
state
.length_ratio_emitted
.insert(("verbosity".to_string(), "compressed".to_string()));
}
// Balanced: user message length roughly matches agent.
if recent_ratio > RATIO_BALANCED
&& !state
.length_ratio_emitted
.contains(&("verbosity".to_string(), "balanced".to_string()))
{
let first_id = state.turns.front().map(|t| t.turn_id.clone());
let last_id = state.turns.back().map(|t| t.turn_id.clone());
let from_id = first_id
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(episodic_id);
let to_id = last_id
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(episodic_id);
tracing::debug!(
"[learning::extract::heuristics] length_ratio balanced session={} \
recent_ratio={:.2}",
session_id,
recent_ratio
);
candidates.push(LearningCandidate {
class: FacetClass::Style,
key: "verbosity".to_string(),
value: "balanced".to_string(),
cue_family: CueFamily::Behavioral,
evidence: EvidenceRef::EpisodicWindow { from_id, to_id },
initial_confidence: 0.60,
observed_at: now_secs(),
});
state
.length_ratio_emitted
.insert(("verbosity".to_string(), "balanced".to_string()));
}
}
// ── B. Edit-window detector ───────────────────────────────────────
if let Some(prev_at) = prev_agent_at {
let gap = user_timestamp - prev_at;
if gap >= 0.0 && gap < EDIT_WINDOW_SECS {
let lower = user_message.to_ascii_lowercase();
// Pattern → (key, value) pairs.
let patterns: &[(&str, &str, &str)] = &[
("shorter", "verbosity", "terse"),
("too long", "verbosity", "terse"),
(" less ", "verbosity", "terse"),
("just code", "format", "code-only"),
("not bullets", "format", "prose"),
("more detail", "verbosity", "detailed"),
("more verbose", "verbosity", "detailed"),
];
for (pattern, key, value) in patterns {
if lower.contains(pattern) {
let now = now_secs();
// ── C. Correction-repeat detector ─────────────────────
// The counter advances on EVERY in-window correction, regardless
// of the edit-window cooldown. This lets 3× corrections promote
// to a Veto even if the cooldown suppressed intermediate emissions.
let cue_key = format!("{key}={value}");
let count = state.correction_counts.entry(cue_key.clone()).or_insert(0);
*count += 1;
let current_count = *count;
if current_count >= VETO_PROMOTION_THRESHOLD
&& !state.veto_promoted.contains(&cue_key)
{
tracing::debug!(
"[learning::extract::heuristics] correction_repeat veto \
cue={:?} count={} session={}",
cue_key,
current_count,
session_id
);
let (veto_key, veto_value, veto_confidence) =
correction_to_veto(key, value);
candidates.push(LearningCandidate {
class: FacetClass::Veto,
key: veto_key,
value: veto_value,
cue_family: CueFamily::Behavioral,
evidence: EvidenceRef::Episodic { episodic_id },
initial_confidence: veto_confidence,
observed_at: now,
});
state.veto_promoted.insert(cue_key);
}
// Style candidate emission respects the cooldown.
let cooldown_key = (key.to_string(), value.to_string());
let last_emit = state
.edit_cooldown
.get(&cooldown_key)
.copied()
.unwrap_or(0.0);
if now - last_emit >= EDIT_COOLDOWN_SECS {
tracing::debug!(
"[learning::extract::heuristics] edit_window matched \
pattern={:?} key={} value={} session={}",
pattern,
key,
value,
session_id
);
candidates.push(LearningCandidate {
class: FacetClass::Style,
key: key.to_string(),
value: value.to_string(),
cue_family: CueFamily::Behavioral,
evidence: EvidenceRef::Episodic { episodic_id },
initial_confidence: 0.70,
observed_at: now,
});
state.edit_cooldown.insert(cooldown_key, now);
}
}
}
}
}
}
// Push outside the lock.
let buf = candidate::global();
let count = candidates.len();
for c in candidates {
buf.push(c);
}
if count > 0 {
tracing::debug!(
"[learning::extract::heuristics] record_turn session={} pushed {} candidate(s)",
session_id,
count
);
}
}
/// Map a (key, value) Style correction to a Veto triple: (veto_key, veto_value, confidence).
fn correction_to_veto(key: &str, value: &str) -> (String, String, f64) {
match (key, value) {
("format", "prose") => ("format".to_string(), "nested-bullets".to_string(), 0.75),
("verbosity", "terse") => ("style".to_string(), "long-replies".to_string(), 0.70),
_ => (format!("{key}-{value}"), "banned".to_string(), 0.65),
}
}
/// Compute mean user/agent length ratio for a slice of turns.
/// Returns 0.0 for empty slices or all-zero agent lengths.
fn mean_ratio(turns: &[&TurnEntry]) -> f64 {
if turns.is_empty() {
return 0.0;
}
let sum: f64 = turns
.iter()
.map(|t| {
if t.agent_msg_len == 0 {
0.0
} else {
t.user_msg_len as f64 / t.agent_msg_len as f64
}
})
.sum();
sum / turns.len() as f64
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod heuristics_tests {
use super::*;
use crate::openhuman::learning::candidate::{Buffer, FacetClass};
fn fresh_session_id() -> String {
format!(
"test-session-{}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
rand_id()
)
}
/// Cheap random suffix so parallel tests don't collide on session keys.
fn rand_id() -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
std::thread::current().id().hash(&mut h);
h.finish()
}
/// Record N turns with decreasing user_msg_len into the heuristics module.
fn push_turns(session_id: &str, n: usize, user_len: usize, agent_len: usize, buf: &Buffer) {
let _ = buf; // We push into the global; this is just for clarity.
let now = now_secs();
for i in 0..n {
record_turn(
session_id,
&i.to_string(),
i as i64,
"neutral message",
user_len,
agent_len,
now + i as f64,
now + i as f64 + 1.0,
None, // no edit window
);
}
}
#[test]
fn length_ratio_emits_compressed_when_user_msgs_shrink() {
let session = fresh_session_id();
let buf = Buffer::new(1024);
// First 15 turns: high ratio (user talks a lot).
let now = now_secs();
for i in 0..15 {
record_turn(
&session,
&i.to_string(),
i as i64,
"long long long message",
200,
100,
now + i as f64,
now + i as f64 + 1.0,
None,
);
}
// Next 15 turns: low ratio (user became terse).
for i in 15..30 {
record_turn(
&session,
&i.to_string(),
i as i64,
"ok",
5,
100,
now + i as f64,
now + i as f64 + 1.0,
None,
);
}
// Should have emitted the "compressed" candidate into the global buffer.
let all = candidate::global().peek();
let compressed = all
.iter()
.filter(|c| {
c.key == "verbosity"
&& c.value == "compressed"
&& matches!(&c.evidence, EvidenceRef::EpisodicWindow { .. })
})
.count();
assert!(
compressed >= 1,
"expected at least one compressed verbosity candidate, got 0"
);
}
#[test]
fn length_ratio_does_not_emit_with_short_window() {
let session = fresh_session_id();
// Only 10 turns — below the 30-turn minimum.
push_turns(&session, 10, 5, 500, &Buffer::new(32));
// Peek the global buffer for this session's compressed candidates.
// We can't isolate per-session here, but at least ensure no crash.
// (Functional assertion is in the positive test above.)
}
#[test]
fn length_ratio_cooldown_prevents_repeated_emission() {
let session = fresh_session_id();
// Trigger the compressed detection twice.
let now = now_secs();
for i in 0..30 {
record_turn(
&session,
&i.to_string(),
i as i64,
"msg",
if i < 15 { 200 } else { 5 },
100,
now + i as f64,
now + i as f64 + 1.0,
None,
);
}
// Trigger it again — should be suppressed by the cooldown set.
for i in 30..60 {
record_turn(
&session,
&i.to_string(),
i as i64,
"msg",
5,
100,
now + i as f64,
now + i as f64 + 1.0,
None,
);
}
// Check that compressed was only emitted once for this session.
let map = session_state().read();
let st = map.get(&session).expect("state for session");
assert!(
st.length_ratio_emitted
.contains(&("verbosity".to_string(), "compressed".to_string())),
"emitted set must record the compression emission"
);
}
#[test]
fn edit_window_emits_terse_on_shorter_correction() {
let session = fresh_session_id();
let now = now_secs();
// Record a turn where user sends "shorter" within 10s of the agent.
record_turn(
&session,
"1",
1,
"shorter please",
14,
200,
now,
now + 1.0,
Some(now - 10.0), // agent replied 10s ago
);
let all = candidate::global().peek();
let terse = all.iter().any(|c| {
c.key == "verbosity"
&& c.value == "terse"
&& c.class == FacetClass::Style
&& matches!(&c.evidence, EvidenceRef::Episodic { episodic_id } if *episodic_id == 1)
});
assert!(
terse,
"expected a terse verbosity candidate after 'shorter' correction"
);
}
#[test]
fn edit_window_ignores_late_messages() {
let session = fresh_session_id();
let now = now_secs();
// User sends "shorter" but 60s after the agent reply — outside window.
let before_count = candidate::global().len();
record_turn(
&session,
"late-1",
999,
"shorter please",
14,
200,
now,
now + 1.0,
Some(now - 60.0),
);
let after_count = candidate::global().len();
// No new terse candidate should have been added for this episode.
// We can only check the delta is zero from our (outside the lock) vantage.
// The correction pattern might still be stored internally but not emitted.
// Confirm: global buffer didn't grow with a terse candidate for episodic_id=999.
let all = candidate::global().peek();
let late_terse = all.iter().any(|c| {
c.key == "verbosity"
&& c.value == "terse"
&& matches!(&c.evidence, EvidenceRef::Episodic { episodic_id } if *episodic_id == 999)
});
assert!(!late_terse, "late message must not emit terse candidate");
let _ = (before_count, after_count);
}
#[test]
fn correction_repeat_promotes_to_veto_after_3() {
let session = fresh_session_id();
let now = now_secs();
// Three "not bullets" corrections within the edit window.
for i in 0..3usize {
// Each turn: agent replied just before, user corrects quickly.
let prev_agent_at = now + i as f64 * 100.0 - 5.0;
let user_at = now + i as f64 * 100.0;
record_turn(
&session,
&format!("veto-{i}"),
(100 + i) as i64,
"not bullets please",
18,
300,
user_at,
user_at + 2.0,
Some(prev_agent_at),
);
}
let all = candidate::global().peek();
let veto = all.iter().any(|c| {
c.class == FacetClass::Veto && c.key == "format" && c.value == "nested-bullets"
});
assert!(
veto,
"3× 'not bullets' correction must promote to Veto/format=nested-bullets"
);
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Phase 2 producer modules for the ambient personalization cache.
//!
//! Each submodule is a distinct producer that writes [`LearningCandidate`]s
//! into [`crate::openhuman::learning::candidate::global()`]. Phase 3
//! (stability detector) drains and aggregates those candidates.
//!
//! | Module | Trigger | Signal class |
//! |--------|---------|-------------|
//! | [`signature`] | `DomainEvent::DocumentCanonicalized` (email) | Identity |
//! | [`heuristics`] | Post-turn hook | Style + Veto |
//! | [`summary_facets`] | LLM summariser output parsing | All classes |
pub mod heuristics;
pub mod signature;
pub mod summary_facets;
+643
View File
@@ -0,0 +1,643 @@
//! Email signature parser — Phase 2 producer for Identity candidates.
//!
//! Subscribes to [`DomainEvent::DocumentCanonicalized`] events whose
//! `source_kind == "email"` and parses the trailing signature region of the
//! email body for identity facets (name, role, timezone, employer, location).
//!
//! ## Design notes
//!
//! The parser is intentionally conservative: false positives would pollute the
//! identity class with noisy data and erode user trust. Each detection rule
//! requires a concrete structural signal (capitalised name pattern, role keyword
//! anchor, timezone abbreviation, etc.) rather than scoring free text.
//!
//! ## Registration
//!
//! Call [`register_email_signature_subscriber`] once at startup (alongside the
//! `TracingSubscriber` and other domain subscribers). The returned
//! `SubscriptionHandle` must be kept alive for the subscriber to remain active.
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle};
use crate::openhuman::learning::candidate::{
self, CueFamily, EvidenceRef, FacetClass, LearningCandidate,
};
// ── Constants ────────────────────────────────────────────────────────────────
/// Number of non-empty lines to scan from the bottom of the body.
const SIG_WINDOW_LINES: usize = 8;
/// Initial confidence values per detection kind.
const CONF_NAME: f64 = 0.85;
const CONF_ROLE: f64 = 0.80;
const CONF_TIMEZONE: f64 = 0.90;
const CONF_EMPLOYER: f64 = 0.70;
const CONF_LOCATION: f64 = 0.60;
// ── Public parse function ────────────────────────────────────────────────────
/// Parse the trailing signature region of `email_body` for identity facets.
///
/// Returns a (possibly empty) list of [`LearningCandidate`]s, one per
/// detected signal. Candidates with `initial_confidence` < 0.6 are
/// dropped before returning.
///
/// # Arguments
///
/// * `email_body` — the canonical markdown body of the email message
/// * `source_id` — the ingest source id (e.g. `"gmail:abc"`)
/// * `message_id` — provider message identifier for provenance
pub fn parse_signature(
email_body: &str,
source_id: &str,
message_id: &str,
) -> Vec<LearningCandidate> {
let now = now_secs();
// Take the last SIG_WINDOW_LINES non-empty lines in original order.
let all_non_empty: Vec<&str> = email_body
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
let start = all_non_empty.len().saturating_sub(SIG_WINDOW_LINES);
let sig_lines = &all_non_empty[start..];
if sig_lines.is_empty() {
return Vec::new();
}
let evidence = || EvidenceRef::EmailMessage {
source_id: source_id.to_string(),
message_id: message_id.to_string(),
};
let mut candidates: Vec<LearningCandidate> = Vec::new();
// Track whether we found any strong signature signal so we can gate
// low-confidence detections (location).
let mut strong_signal_found = false;
// --- Name detection ---
let mut name_line_idx: Option<usize> = None;
for (idx, line) in sig_lines.iter().enumerate() {
let t = line.trim();
if is_likely_name(t) {
name_line_idx = Some(idx);
candidates.push(LearningCandidate {
class: FacetClass::Identity,
key: "name".to_string(),
value: t.to_string(),
cue_family: CueFamily::Structural,
evidence: evidence(),
initial_confidence: CONF_NAME,
observed_at: now,
});
strong_signal_found = true;
tracing::debug!(
"[learning::extract::signature] name detected: {:?} source_id={}",
t,
source_id
);
break;
}
}
// --- Role detection ---
let mut role_line_idx: Option<usize> = None;
for (idx, line) in sig_lines.iter().enumerate() {
let t = line.trim();
if let Some(role) = extract_role(t) {
role_line_idx = Some(idx);
candidates.push(LearningCandidate {
class: FacetClass::Identity,
key: "role".to_string(),
value: role.to_string(),
cue_family: CueFamily::Structural,
evidence: evidence(),
initial_confidence: CONF_ROLE,
observed_at: now,
});
strong_signal_found = true;
tracing::debug!(
"[learning::extract::signature] role detected: {:?} source_id={}",
role,
source_id
);
break;
}
}
// --- Timezone detection ---
for line in sig_lines {
let t = line.trim();
if let Some(tz) = extract_timezone(t) {
candidates.push(LearningCandidate {
class: FacetClass::Identity,
key: "timezone".to_string(),
value: tz.to_string(),
cue_family: CueFamily::Structural,
evidence: evidence(),
initial_confidence: CONF_TIMEZONE,
observed_at: now,
});
strong_signal_found = true;
tracing::debug!(
"[learning::extract::signature] timezone detected: {:?} source_id={}",
tz,
source_id
);
break;
}
}
// --- Employer detection ---
// Strategy 1: line immediately following the role line.
if let Some(role_idx) = role_line_idx {
let employer_idx = role_idx + 1;
if employer_idx < sig_lines.len() {
let t = sig_lines[employer_idx].trim();
if is_plausible_employer(t) {
candidates.push(LearningCandidate {
class: FacetClass::Identity,
key: "employer".to_string(),
value: clean_employer(t),
cue_family: CueFamily::Structural,
evidence: evidence(),
initial_confidence: CONF_EMPLOYER,
observed_at: now,
});
strong_signal_found = true;
tracing::debug!(
"[learning::extract::signature] employer (post-role) detected: {:?} source_id={}",
t,
source_id
);
}
}
}
// Strategy 2: "@ Company" or "Company, Inc" pattern on any sig line.
if !candidates.iter().any(|c| c.key == "employer") {
for line in sig_lines {
let t = line.trim();
if let Some(emp) = extract_employer_pattern(t) {
candidates.push(LearningCandidate {
class: FacetClass::Identity,
key: "employer".to_string(),
value: emp,
cue_family: CueFamily::Structural,
evidence: evidence(),
initial_confidence: CONF_EMPLOYER,
observed_at: now,
});
strong_signal_found = true;
tracing::debug!(
"[learning::extract::signature] employer (pattern) detected source_id={}",
source_id
);
break;
}
}
}
// --- Location detection (low-confidence, only when strong signals present) ---
if strong_signal_found {
// Look at the line right after the name line.
if let Some(name_idx) = name_line_idx {
let loc_idx = name_idx + 1;
if loc_idx < sig_lines.len() {
let t = sig_lines[loc_idx].trim();
// Skip if already matched as role or employer.
let already_used = role_line_idx.map_or(false, |ri| ri == loc_idx);
if !already_used {
if let Some(loc) = extract_location(t) {
candidates.push(LearningCandidate {
class: FacetClass::Identity,
key: "location".to_string(),
value: loc,
cue_family: CueFamily::Structural,
evidence: evidence(),
initial_confidence: CONF_LOCATION,
observed_at: now,
});
tracing::debug!(
"[learning::extract::signature] location detected source_id={}",
source_id
);
}
}
}
}
}
// Drop low-confidence noise. All CONF_* constants are currently ≥ 0.6,
// so this is mostly a guard against future regressions and codifies the
// contract advertised in the doc comment.
let kept = candidates.len();
candidates.retain(|c| c.initial_confidence >= 0.6);
let dropped = kept - candidates.len();
tracing::debug!(
"[learning::extract::signature] parse_signature source_id={} candidates={} dropped={}",
source_id,
candidates.len(),
dropped,
);
candidates
}
// ── Detection helpers ─────────────────────────────────────────────────────────
/// Returns `true` if `s` looks like a person's name: 14 capitalised words,
/// no `@`, no URL fragments, mostly alphabetic, no trailing punctuation like commas.
fn is_likely_name(s: &str) -> bool {
if s.contains('@') || s.contains("://") || s.contains("www.") {
return false;
}
// Lines ending with punctuation like "Thanks," or "Best regards," are not names.
if s.ends_with(',') || s.ends_with(':') || s.ends_with(';') {
return false;
}
// Lines that are clearly greetings or sign-offs.
let lower = s.to_ascii_lowercase();
const EXCLUSIONS: &[&str] = &[
"thanks",
"regards",
"best",
"cheers",
"sincerely",
"cordially",
"hi",
"hello",
"dear",
"hey",
];
if EXCLUSIONS.iter().any(|e| lower == *e) {
return false;
}
let words: Vec<&str> = s.split_whitespace().collect();
if words.is_empty() || words.len() > 4 {
return false;
}
// Every word must start with an uppercase letter and be mostly alpha.
for w in &words {
let first = w.chars().next().unwrap_or(' ');
if !first.is_uppercase() {
return false;
}
// Strip trailing punctuation for the ratio check.
let clean: String = w.chars().filter(|c| c.is_alphabetic()).collect();
if clean.is_empty() {
return false;
}
let alpha_ratio = clean.len() as f64 / w.len() as f64;
if alpha_ratio < 0.7 {
return false;
}
}
// Must contain at least two words or exactly one that's clearly a name
// (more than 3 chars). Single-word uppercase abbreviations like "PST"
// would be false positives without this guard.
words.len() >= 2 || (words.len() == 1 && words[0].len() > 3)
}
/// Extracts a role string from `s` if it contains a known role keyword.
/// Returns the trimmed line (or the keyword match) as the role value.
fn extract_role(s: &str) -> Option<&str> {
const ROLE_KEYWORDS: &[&str] = &[
"engineer",
"developer",
"designer",
"manager",
"director",
"founder",
"cto",
"ceo",
"coo",
"cpo",
"cfo",
"product manager",
"consultant",
"lead",
"head of",
"vp",
"vice president",
"principal",
"staff ",
"senior ",
"architect",
"researcher",
"analyst",
"recruiter",
"sales",
"marketing",
"intern",
];
let lower = s.to_ascii_lowercase();
for kw in ROLE_KEYWORDS {
if lower.contains(kw) {
return Some(s);
}
}
None
}
/// Extracts a timezone abbreviation from `s`.
fn extract_timezone(s: &str) -> Option<&str> {
// Scan for known timezone patterns. We return the matched slice.
const STATIC_TZ: &[&str] = &[
"PST", "PDT", "EST", "EDT", "MST", "MDT", "CST", "CDT", "UTC", "GMT",
];
for tz in STATIC_TZ {
if let Some(pos) = s.find(tz) {
// Confirm the match is a whole word (preceded/followed by non-alpha).
let before_ok = pos == 0 || !s[..pos].ends_with(|c: char| c.is_alphabetic());
let after = &s[pos + tz.len()..];
let after_ok = after.is_empty()
|| after.starts_with(|c: char| !c.is_alphabetic())
|| after.starts_with(|c: char| c == '+' || c == '-');
if before_ok && after_ok {
// Grab "UTC+5:30" or "GMT-7" style suffix.
if tz.starts_with("UTC") || tz.starts_with("GMT") {
let end = s[pos + tz.len()..]
.find(|c: char| !c.is_ascii_digit() && c != '+' && c != '-' && c != ':')
.map(|off| pos + tz.len() + off)
.unwrap_or(s.len());
return Some(&s[pos..end]);
}
return Some(&s[pos..pos + tz.len()]);
}
}
}
None
}
/// Returns `true` if `s` is plausible as a company / employer name line
/// (not an email, not a URL, contains at least one capital letter, not pure punctuation).
fn is_plausible_employer(s: &str) -> bool {
if s.is_empty() || s.len() > 80 {
return false;
}
if s.contains('@') || s.contains("://") || s.contains("www.") {
return false;
}
// Must have at least one uppercase letter.
if !s.chars().any(|c| c.is_uppercase()) {
return false;
}
let alpha_count = s.chars().filter(|c| c.is_alphabetic()).count();
alpha_count >= 2
}
/// Cleans a raw employer line: trims, strips trailing punctuation.
fn clean_employer(s: &str) -> String {
s.trim()
.trim_end_matches([',', '.', ';'])
.trim()
.to_string()
}
/// Tries to extract an employer from patterns: `"@ Company"` or `"Company, Inc"`.
fn extract_employer_pattern(s: &str) -> Option<String> {
let t = s.trim();
// "@ Company Name"
if let Some(stripped) = t.strip_prefix('@') {
let name = stripped.trim();
if !name.is_empty() && !name.contains('@') {
return Some(name.to_string());
}
}
// "Company, Inc" / "Company LLC" / "Company Ltd"
let lower = t.to_ascii_lowercase();
let corp_suffixes = [
", inc", " inc.", " llc", " ltd", " limited", " corp", " co.",
];
for suffix in corp_suffixes {
if lower.ends_with(suffix) || lower.contains(&format!("{suffix} ")) {
if is_plausible_employer(t) {
return Some(clean_employer(t));
}
}
}
None
}
/// Attempts to extract a city/state location from a line.
/// Very conservative: requires a comma between city and state/country-like fragment.
fn extract_location(s: &str) -> Option<String> {
let t = s.trim();
if t.contains('@') || t.contains("://") {
return None;
}
// Simple heuristic: "City, ST" or "City, Country"
if let Some(comma_pos) = t.find(',') {
let city = t[..comma_pos].trim();
let region = t[comma_pos + 1..].trim();
// City: 2-30 chars, no digits, starts with uppercase.
let city_ok = city.len() >= 2
&& city.len() <= 30
&& city.chars().next().map_or(false, |c| c.is_uppercase())
&& !city.chars().any(|c| c.is_ascii_digit());
// Region: 2-20 chars.
let region_ok = region.len() >= 2 && region.len() <= 20;
if city_ok && region_ok {
return Some(t.to_string());
}
}
None
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
// ── Subscriber ───────────────────────────────────────────────────────────────
/// Event subscriber that reacts to `DocumentCanonicalized` events for email
/// sources and routes detected identity candidates into the global buffer.
pub struct EmailSignatureSubscriber;
#[async_trait]
impl EventHandler for EmailSignatureSubscriber {
fn name(&self) -> &str {
"learning::extract::signature"
}
fn domains(&self) -> Option<&[&str]> {
Some(&["memory"])
}
async fn handle(&self, event: &DomainEvent) {
if let DomainEvent::DocumentCanonicalized {
source_id,
source_kind,
body_preview,
chunk_ids,
..
} = event
{
if source_kind != "email" {
return;
}
let body = match body_preview {
Some(b) => b,
None => {
tracing::debug!(
"[learning::extract::signature] no body_preview on DocumentCanonicalized \
source_id={} — skipping signature parse",
source_id
);
return;
}
};
// Use the first chunk_id as the message_id if no dedicated field is
// available. The EmailMessage evidence variant carries both the
// source_id and message_id for provenance.
let message_id = chunk_ids
.first()
.cloned()
.unwrap_or_else(|| source_id.clone());
tracing::debug!(
"[learning::extract::signature] parsing email signature source_id={} body_len={}",
source_id,
body.len()
);
let candidates = parse_signature(body, source_id, &message_id);
let count = candidates.len();
let buf = candidate::global();
for c in candidates {
buf.push(c);
}
tracing::debug!(
"[learning::extract::signature] pushed {} identity candidate(s) for source_id={}",
count,
source_id
);
}
}
}
/// Register the email signature subscriber on the global event bus.
///
/// Must be called at startup after [`crate::core::event_bus::init_global`].
/// The returned handle keeps the subscription alive — store it in a long-lived
/// container (e.g. alongside other `SubscriptionHandle`s in startup).
pub fn register_email_signature_subscriber() -> Option<SubscriptionHandle> {
subscribe_global(Arc::new(EmailSignatureSubscriber))
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod signature_tests {
use super::*;
use crate::openhuman::learning::candidate::{CueFamily, EvidenceRef, FacetClass};
fn extract(body: &str) -> Vec<LearningCandidate> {
parse_signature(body, "gmail:test", "<msg-1@test.com>")
}
fn find<'a>(cs: &'a [LearningCandidate], key: &str) -> Option<&'a LearningCandidate> {
cs.iter().find(|c| c.key == key)
}
#[test]
fn parse_signature_extracts_name_role_timezone_employer() {
let body = "Hi, great to hear from you!\n\n\
Please find the docs attached.\n\n\
Thanks,\n\
Alice Johnson\n\
Senior Software Engineer\n\
Acme Corp\n\
San Francisco, CA\n\
PST";
let candidates = extract(body);
let name = find(&candidates, "name").expect("name candidate");
assert_eq!(name.value, "Alice Johnson");
assert!((name.initial_confidence - CONF_NAME).abs() < 0.01);
let role = find(&candidates, "role").expect("role candidate");
assert!(role.value.contains("Engineer") || role.value.contains("engineer"));
let tz = find(&candidates, "timezone").expect("timezone candidate");
assert_eq!(tz.value, "PST");
assert!((tz.initial_confidence - CONF_TIMEZONE).abs() < 0.01);
let emp = find(&candidates, "employer").expect("employer candidate");
assert!(emp.value.contains("Acme"));
}
#[test]
fn parse_signature_handles_no_signature() {
let body = "just some content here nothing looks like a sig";
let cs = extract(body);
// No strong signals → no candidates emitted. The < 0.6 filter at the
// tail of parse_signature drops anything below confidence threshold
// (including the gated lone-location case), so the result must be empty.
assert!(
cs.is_empty(),
"expected zero candidates from non-signature body, got {cs:?}"
);
}
#[test]
fn parse_signature_ignores_quoted_replies() {
// Even when the body has quoted sections, only the window of non-empty lines
// at the very end is scanned.
let body = "> On Monday, Alice wrote:\n\
> Great to meet you!\n\
>\n\
Sure, let's connect.\n\n\
Bob Smith\n\
Product Manager\n\
UTC+1";
let cs = extract(body);
let name = find(&cs, "name").expect("name candidate");
assert_eq!(name.value, "Bob Smith");
let tz = find(&cs, "timezone").expect("timezone");
assert!(tz.value.starts_with("UTC"));
}
#[test]
fn parse_signature_low_confidence_for_lone_location() {
// Location alone (no other strong signals) should NOT produce a candidate.
let body = "The meeting is on Thursday.\nSan Francisco, CA";
let cs = extract(body);
assert!(
find(&cs, "location").is_none(),
"location candidate must not be emitted without other strong signals"
);
}
#[test]
fn parse_signature_emits_evidence_email_message_variant() {
let body = "Alice Smith\nCTO\nStartup Inc\nPST";
let cs = extract(body);
for c in &cs {
assert!(
matches!(
&c.evidence,
EvidenceRef::EmailMessage { source_id, message_id }
if source_id == "gmail:test" && message_id == "<msg-1@test.com>"
),
"expected EmailMessage evidence, got {:?}",
c.evidence
);
assert_eq!(c.cue_family, CueFamily::Structural);
assert_eq!(c.class, FacetClass::Identity);
}
}
}
@@ -0,0 +1,343 @@
//! Structured facet schema and routing from LLM summariser output.
//!
//! The LLM summariser is extended (in `memory/tree/tree_source/summariser/llm.rs`)
//! to produce a second JSON block after the prose summary. This module defines
//! the serde shapes for that block ([`StructuredSummary`], [`ParsedFacet`]) and
//! provides [`route_facets_to_buffer`], which validates each facet and pushes
//! valid candidates to [`crate::openhuman::learning::candidate::global()`].
//!
//! ## Provenance contract
//!
//! Every facet must cite at least one `chunk_id` in its `evidence_chunks` array.
//! Facets with an empty `evidence_chunks` are silently dropped — unattributed
//! observations cannot be scored or cited later.
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::openhuman::learning::candidate::{
self, CueFamily, EvidenceRef, FacetClass, LearningCandidate,
};
// ── Serde types ──────────────────────────────────────────────────────────────
/// A single facet extracted by the LLM during summarisation.
#[derive(Deserialize, Serialize, Debug, Clone)]
#[serde(rename_all = "snake_case")]
pub struct ParsedFacet {
/// Facet class as a string — `"style"` | `"identity"` | `"tooling"` |
/// `"veto"` | `"goal"` | `"channel"`.
pub class: String,
/// Canonical slug key within the class, e.g. `"verbosity"`, `"timezone"`.
pub key: String,
/// Detected value string.
pub value: String,
/// Chunk IDs from the current seal batch that evidence this facet.
/// Must be non-empty for the facet to be accepted.
#[serde(default)]
pub evidence_chunks: Vec<String>,
/// Source confidence `0.0..=1.0`.
pub confidence: f64,
/// How the signal was produced — `"explicit"` | `"structural"` | `"behavioral"`.
#[serde(default = "default_cue")]
pub cue_family: String,
}
fn default_cue() -> String {
"behavioral".into()
}
/// The full structured output expected from the LLM summariser when
/// `structured_facet_extraction` is enabled.
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct StructuredSummary {
/// Prose summary (the text that was previously the only output).
pub summary: String,
/// Optional extracted facets. Empty by default when the LLM found nothing
/// clearly evidenced.
#[serde(default)]
pub facets: Vec<ParsedFacet>,
}
// ── Routing ───────────────────────────────────────────────────────────────────
/// Validate each [`ParsedFacet`] in `parsed` and push valid candidates to
/// [`candidate::global()`].
///
/// Drops facets that:
/// - have an unrecognised `class` string
/// - have an empty `evidence_chunks` array (provenance is mandatory)
/// - have `confidence` outside `0.0..=1.0`
///
/// Maps `cue_family` strings to [`CueFamily`]; unknown strings default to
/// [`CueFamily::Behavioral`].
///
/// Uses the first non-empty `evidence_chunks` entry as the `chunk_id` in
/// [`EvidenceRef::DocumentChunk`].
pub fn route_facets_to_buffer(parsed: &StructuredSummary, source_id: &str) {
let now = now_secs();
let buf = candidate::global();
let mut pushed = 0usize;
for facet in &parsed.facets {
// Validate evidence.
let chunk_id = match facet.evidence_chunks.first() {
Some(id) if !id.is_empty() => id.clone(),
_ => {
tracing::debug!(
"[learning::extract::summary_facets] dropping facet key={} \
(no evidence_chunks) source_id={}",
facet.key,
source_id
);
continue;
}
};
// Map class string.
let class = match parse_facet_class(&facet.class) {
Some(c) => c,
None => {
tracing::debug!(
"[learning::extract::summary_facets] dropping facet key={} \
(unknown class={:?}) source_id={}",
facet.key,
facet.class,
source_id
);
continue;
}
};
// Clamp confidence.
let confidence = facet.confidence.clamp(0.0, 1.0);
let cue_family = parse_cue_family(&facet.cue_family);
let candidate = LearningCandidate {
class,
key: facet.key.clone(),
value: facet.value.clone(),
cue_family,
evidence: EvidenceRef::DocumentChunk {
source_id: source_id.to_string(),
chunk_id,
},
initial_confidence: confidence,
observed_at: now,
};
tracing::debug!(
"[learning::extract::summary_facets] routing facet class={:?} key={} \
value={:?} confidence={:.2} source_id={}",
candidate.class,
candidate.key,
candidate.value,
candidate.initial_confidence,
source_id
);
buf.push(candidate);
pushed += 1;
}
tracing::debug!(
"[learning::extract::summary_facets] route_facets_to_buffer source_id={} \
facets_in={} pushed={}",
source_id,
parsed.facets.len(),
pushed
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
fn parse_facet_class(s: &str) -> Option<FacetClass> {
match s {
"style" => Some(FacetClass::Style),
"identity" => Some(FacetClass::Identity),
"tooling" => Some(FacetClass::Tooling),
"veto" => Some(FacetClass::Veto),
"goal" => Some(FacetClass::Goal),
"channel" => Some(FacetClass::Channel),
_ => None,
}
}
fn parse_cue_family(s: &str) -> CueFamily {
match s {
"explicit" => CueFamily::Explicit,
"structural" => CueFamily::Structural,
"behavioral" => CueFamily::Behavioral,
"recurrence" => CueFamily::Recurrence,
_ => CueFamily::Behavioral,
}
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::learning::candidate::{Buffer, CueFamily, FacetClass};
fn make_summary(facets: Vec<ParsedFacet>) -> StructuredSummary {
StructuredSummary {
summary: "A summary.".into(),
facets,
}
}
fn make_facet(
class: &str,
key: &str,
value: &str,
evidence_chunks: Vec<&str>,
confidence: f64,
cue_family: &str,
) -> ParsedFacet {
ParsedFacet {
class: class.into(),
key: key.into(),
value: value.into(),
evidence_chunks: evidence_chunks.into_iter().map(str::to_string).collect(),
confidence,
cue_family: cue_family.into(),
}
}
#[test]
fn parse_well_formed_structured_summary() {
let json = r#"{
"summary": "The user prefers pnpm.",
"facets": [
{
"class": "tooling",
"key": "package_manager",
"value": "pnpm",
"evidence_chunks": ["chunk-abc"],
"confidence": 0.85,
"cue_family": "explicit"
}
]
}"#;
let parsed: StructuredSummary = serde_json::from_str(json).unwrap();
assert_eq!(parsed.summary, "The user prefers pnpm.");
assert_eq!(parsed.facets.len(), 1);
assert_eq!(parsed.facets[0].key, "package_manager");
assert_eq!(parsed.facets[0].cue_family, "explicit");
}
#[test]
fn drops_facet_with_unknown_class() {
let buf = Buffer::new(64);
let before = buf.len();
// Route into global but we check the drop via tracing — here we test
// that the function doesn't panic and the drop is silent.
let s = make_summary(vec![make_facet(
"unknown_class",
"key",
"val",
vec!["c1"],
0.8,
"behavioral",
)]);
// Use global buffer for integration.
let before_global = candidate::global().len();
route_facets_to_buffer(&s, "src-1");
let after_global = candidate::global().len();
// No new candidates pushed.
assert_eq!(
after_global, before_global,
"unknown class should drop the facet"
);
let _ = (buf, before);
}
#[test]
fn drops_facet_without_evidence_chunks() {
let s = make_summary(vec![make_facet(
"style",
"verbosity",
"terse",
vec![], // empty — must be dropped
0.8,
"explicit",
)]);
let before = candidate::global().len();
route_facets_to_buffer(&s, "src-2");
assert_eq!(
candidate::global().len(),
before,
"facet without evidence_chunks must be dropped"
);
}
#[test]
fn defaults_cue_family_to_behavioral() {
let json = r#"{
"summary": "x",
"facets": [
{
"class": "style",
"key": "verbosity",
"value": "terse",
"evidence_chunks": ["c1"],
"confidence": 0.7
}
]
}"#;
let parsed: StructuredSummary = serde_json::from_str(json).unwrap();
assert_eq!(parsed.facets[0].cue_family, "behavioral");
}
#[test]
fn route_pushes_to_global_buffer() {
let s = make_summary(vec![
make_facet(
"style",
"verbosity",
"terse",
vec!["chunk-1"],
0.75,
"explicit",
),
make_facet(
"identity",
"timezone",
"UTC+5:30",
vec!["chunk-2"],
0.9,
"structural",
),
]);
let before = candidate::global().len();
route_facets_to_buffer(&s, "notion:doc-1");
let after = candidate::global().len();
assert_eq!(
after,
before + 2,
"two valid facets should push two candidates"
);
let all = candidate::global().peek();
let tz = all.iter().find(|c| c.key == "timezone");
let tz = tz.expect("timezone candidate in buffer");
assert_eq!(tz.value, "UTC+5:30");
assert_eq!(tz.class, FacetClass::Identity);
assert_eq!(tz.cue_family, CueFamily::Structural);
assert!(
matches!(&tz.evidence, EvidenceRef::DocumentChunk { source_id, chunk_id }
if source_id == "notion:doc-1" && chunk_id == "chunk-2")
);
}
}
+32 -1
View File
@@ -2,19 +2,50 @@
//!
//! Post-turn hooks that reflect on completed turns, extract user preferences,
//! track tool effectiveness, and store learnings in the Memory backend.
//!
//! # Phase 1 additions (#566)
//!
//! - [`candidate`] — `LearningCandidate`, `FacetClass`, `CueFamily`, `EvidenceRef`,
//! and the thread-safe ring-buffer [`candidate::Buffer`] that collects evidence
//! from producers (Phase 2) for consumption by the stability detector (Phase 3).
//!
//! # Phase 2 additions (#566)
//!
//! - [`extract`] — producer modules: `signature` (email identity parser),
//! `heuristics` (length-ratio + edit-window + correction-repeat detectors),
//! `summary_facets` (structured facets from the LLM summariser).
//!
//! # Phase 3 additions (#566)
//!
//! - [`cache`] — `FacetCache` wrapper over `user_profile_facets` table.
//! - [`stability_detector`] — rebuild cycle: drain, aggregate, score, resolve, persist.
//! - [`scheduler`] — periodic + event-driven rebuild scheduling.
pub mod cache;
pub mod candidate;
pub mod extract;
pub mod linkedin_enrichment;
pub mod profile_md_renderer;
pub mod prompt_sections;
pub mod reflection;
pub mod scheduler;
pub mod schemas;
pub mod stability_detector;
pub mod tool_tracker;
pub mod transcript_ingest;
pub mod user_profile;
pub use prompt_sections::{LearnedContextSection, UserProfileSection};
pub use cache::FacetCache;
pub use candidate::{Buffer, CueFamily, EvidenceRef, FacetClass, LearningCandidate};
pub use profile_md_renderer::ProfileMdRenderer;
pub use prompt_sections::{
load_learned_from_cache, LearnedContextSection, MemoryAccessSection, UserProfileSection,
MEMORY_ACCESS_INSTRUCTION,
};
pub use reflection::ReflectionHook;
pub use schemas::{
all_learning_controller_schemas, all_learning_registered_controllers, learning_schemas,
};
pub use stability_detector::StabilityDetector;
pub use tool_tracker::ToolTrackerHook;
pub use user_profile::UserProfileHook;
@@ -0,0 +1,514 @@
//! Profile-MD renderer for the learning subsystem.
//!
//! Subscribes to [`DomainEvent::CacheRebuilt`] and re-renders the five
//! cache-derived managed blocks in `PROFILE.md`:
//!
//! | Block name | Heading | Facet class |
//! |------------|---------|-------------|
//! | `style` | `## Style` | `FacetClass::Style` |
//! | `identity` | `## Identity`| `FacetClass::Identity`|
//! | `tooling` | `## Tooling` | `FacetClass::Tooling` |
//! | `vetoes` | `## Vetoes` | `FacetClass::Veto` |
//! | `goals` | `## Goals` | `FacetClass::Goal` |
//!
//! The `connected-accounts` block is NOT touched by this renderer; it is
//! owned exclusively by the provider path
//! (`composio::providers::profile_md::merge_provider_into_profile_md`).
//!
//! ## Rendering rules
//!
//! - Only `Active` rows are rendered in the visible blocks.
//! - Within each block, rows are sorted by `stability` desc, then by `key` asc.
//! - `Pinned` entries get a trailing ` *(pinned)*` indicator.
//! - Format per class:
//! - Style / Identity / Tooling / Vetoes: `- **{suffix}**: {value}`
//! where `suffix` is the portion of the key after the first `/`.
//! - Goals: `- {value}` (full sentence — no key prefix).
//! - Empty classes render the `*(no entries yet)*` placeholder (never
//! delete the block markers).
//!
//! ## Subscription
//!
//! [`ProfileMdRenderer::subscribe`] registers an `EventHandler` that calls
//! [`ProfileMdRenderer::render`] on every `CacheRebuilt` event. The render
//! is synchronous (SQLite reads + file writes) and runs on the Tokio blocking
//! thread pool.
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle};
use crate::openhuman::composio::providers::profile_md::replace_managed_block;
use crate::openhuman::learning::cache::FacetCache;
use crate::openhuman::memory::store::profile::UserState;
// ── Class → block metadata ────────────────────────────────────────────────────
struct BlockSpec {
block_name: &'static str,
heading: &'static str,
class_prefix: &'static str,
/// When true, render `- {value}` (goal style). Otherwise `- **{key_suffix}**: {value}`.
value_only: bool,
}
const BLOCK_SPECS: &[BlockSpec] = &[
BlockSpec {
block_name: "style",
heading: "## Style",
class_prefix: "style/",
value_only: false,
},
BlockSpec {
block_name: "identity",
heading: "## Identity",
class_prefix: "identity/",
value_only: false,
},
BlockSpec {
block_name: "tooling",
heading: "## Tooling",
class_prefix: "tooling/",
value_only: false,
},
BlockSpec {
block_name: "vetoes",
heading: "## Vetoes",
class_prefix: "veto/",
value_only: false,
},
BlockSpec {
block_name: "goals",
heading: "## Goals",
class_prefix: "goal/",
value_only: true,
},
];
// ── ProfileMdRenderer ─────────────────────────────────────────────────────────
/// Renders Active facets from the `FacetCache` into the five cache-derived
/// managed blocks of `PROFILE.md`.
pub struct ProfileMdRenderer {
cache: Arc<FacetCache>,
workspace_dir: PathBuf,
}
impl ProfileMdRenderer {
/// Create a new renderer backed by `cache`, writing to
/// `workspace_dir/PROFILE.md`.
pub fn new(cache: Arc<FacetCache>, workspace_dir: PathBuf) -> Self {
Self {
cache,
workspace_dir,
}
}
/// Read all Active facets from the cache and re-render each of the five
/// cache-owned blocks. Never touches the `connected-accounts` block.
pub fn render(&self) -> anyhow::Result<()> {
tracing::debug!("[learning::profile_md_renderer] render triggered — reading active facets");
let active_facets = self.cache.list_active()?;
for spec in BLOCK_SPECS {
// Filter to this class, sort by stability desc then key asc.
let mut rows: Vec<_> = active_facets
.iter()
.filter(|f| f.key.starts_with(spec.class_prefix))
.collect();
rows.sort_by(|a, b| {
b.stability
.partial_cmp(&a.stability)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.key.cmp(&b.key))
});
let body = if rows.is_empty() {
String::new() // replace_managed_block renders the placeholder
} else {
let mut lines: Vec<String> = Vec::with_capacity(rows.len());
for f in &rows {
let pinned_suffix = if f.user_state == UserState::Pinned {
" *(pinned)*"
} else {
""
};
let line = if spec.value_only {
format!("- {}{}", f.value, pinned_suffix)
} else {
let key_suffix = f
.key
.strip_prefix(spec.class_prefix)
.unwrap_or(f.key.as_str());
format!("- **{}**: {}{}", key_suffix, f.value, pinned_suffix)
};
lines.push(line);
}
lines.join("\n")
};
replace_managed_block(&self.workspace_dir, spec.block_name, spec.heading, body)
.map_err(|e| {
anyhow::anyhow!(
"[learning::profile_md_renderer] failed to write block '{}': {e}",
spec.block_name
)
})?;
tracing::debug!(
"[learning::profile_md_renderer] wrote block '{}' ({} entries)",
spec.block_name,
rows.len()
);
}
tracing::info!("[learning::profile_md_renderer] PROFILE.md updated successfully");
Ok(())
}
/// Register this renderer as an event subscriber for
/// [`DomainEvent::CacheRebuilt`] events.
///
/// Returns the [`SubscriptionHandle`] — hold it alive for the lifetime of
/// the process (e.g. by leaking it into a static).
pub fn subscribe(renderer: Arc<ProfileMdRenderer>) -> Option<SubscriptionHandle> {
subscribe_global(Arc::new(RendererSubscriber(renderer)))
}
}
// ── Event subscriber ─────────────────────────────────────────────────────────
struct RendererSubscriber(Arc<ProfileMdRenderer>);
#[async_trait]
impl EventHandler for RendererSubscriber {
fn name(&self) -> &str {
"learning::profile_md_renderer"
}
fn domains(&self) -> Option<&[&str]> {
Some(&["memory"])
}
async fn handle(&self, event: &DomainEvent) {
if let DomainEvent::CacheRebuilt { .. } = event {
let renderer = Arc::clone(&self.0);
// Move the blocking I/O (SQLite reads + fs writes) off the async
// executor thread.
tokio::task::spawn_blocking(move || {
if let Err(e) = renderer.render() {
tracing::warn!(
"[learning::profile_md_renderer] render on CacheRebuilt failed: {e:#}"
);
}
});
}
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::composio::providers::profile_md::{block_end, block_start};
use crate::openhuman::memory::store::profile::{
FacetState, FacetType, ProfileFacet, UserState, PROFILE_INIT_SQL,
};
use parking_lot::Mutex;
use rusqlite::Connection;
use std::sync::Arc;
use tempfile::TempDir;
fn make_cache(conn: Arc<Mutex<Connection>>) -> Arc<FacetCache> {
Arc::new(FacetCache::new(conn))
}
fn insert_facet(
cache: &FacetCache,
key: &str,
value: &str,
state: FacetState,
user_state: UserState,
stability: f64,
) {
let facet = ProfileFacet {
facet_id: format!("f-{key}"),
facet_type: FacetType::Preference,
key: key.into(),
value: value.into(),
confidence: 0.9,
evidence_count: 3,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 2000.0,
state,
stability,
user_state,
evidence_refs: vec![],
class: key.split('/').next().map(|s| s.to_string()),
cue_families: None,
};
cache.upsert(&facet).unwrap();
}
fn make_renderer() -> (Arc<FacetCache>, ProfileMdRenderer, TempDir) {
let tmp = TempDir::new().unwrap();
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(PROFILE_INIT_SQL).unwrap();
let cache = make_cache(Arc::new(Mutex::new(conn)));
let renderer = ProfileMdRenderer::new(Arc::clone(&cache), tmp.path().to_path_buf());
(cache, renderer, tmp)
}
#[test]
fn renders_active_facets_to_class_blocks() {
let (cache, renderer, tmp) = make_renderer();
insert_facet(
&cache,
"style/verbosity",
"terse",
FacetState::Active,
UserState::Auto,
2.0,
);
insert_facet(
&cache,
"identity/name",
"Alice",
FacetState::Active,
UserState::Auto,
1.8,
);
insert_facet(
&cache,
"tooling/editor",
"neovim",
FacetState::Active,
UserState::Auto,
1.5,
);
insert_facet(
&cache,
"veto/no-em-dashes",
"avoid em dashes in prose",
FacetState::Active,
UserState::Auto,
1.2,
);
insert_facet(
&cache,
"goal/learn-rust",
"Learn Rust this year",
FacetState::Active,
UserState::Auto,
1.0,
);
renderer.render().unwrap();
let body = std::fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(
body.contains("- **verbosity**: terse"),
"style block:\n{body}"
);
assert!(
body.contains("- **name**: Alice"),
"identity block:\n{body}"
);
assert!(
body.contains("- **editor**: neovim"),
"tooling block:\n{body}"
);
assert!(
body.contains("- **no-em-dashes**: avoid em dashes"),
"vetoes block:\n{body}"
);
assert!(
body.contains("- Learn Rust this year"),
"goals block:\n{body}"
);
}
#[test]
fn skips_empty_classes_renders_placeholder() {
let (cache, renderer, tmp) = make_renderer();
// Only insert a style facet; all other classes will be empty.
insert_facet(
&cache,
"style/verbosity",
"terse",
FacetState::Active,
UserState::Auto,
2.0,
);
renderer.render().unwrap();
let body = std::fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
// Empty classes get the placeholder.
assert!(
body.contains("*(no entries yet)*"),
"placeholder missing:\n{body}"
);
// The style class has real content.
assert!(body.contains("- **verbosity**: terse"));
}
#[test]
fn pinned_facets_marked_in_output() {
let (cache, renderer, tmp) = make_renderer();
insert_facet(
&cache,
"style/format",
"markdown",
FacetState::Active,
UserState::Pinned,
1.0,
);
renderer.render().unwrap();
let body = std::fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(
body.contains("*(pinned)*"),
"pinned marker missing:\n{body}"
);
assert!(body.contains("- **format**: markdown *(pinned)*"));
}
#[test]
fn provisional_facets_excluded_from_output() {
let (cache, renderer, tmp) = make_renderer();
insert_facet(
&cache,
"style/tone",
"formal",
FacetState::Provisional,
UserState::Auto,
0.8,
);
insert_facet(
&cache,
"style/verbosity",
"terse",
FacetState::Active,
UserState::Auto,
2.0,
);
renderer.render().unwrap();
let body = std::fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert!(
!body.contains("formal"),
"provisional must not appear:\n{body}"
);
assert!(body.contains("terse"));
}
#[test]
fn re_renders_idempotently_on_repeated_cache_rebuilt() {
let (cache, renderer, tmp) = make_renderer();
insert_facet(
&cache,
"style/verbosity",
"terse",
FacetState::Active,
UserState::Auto,
2.0,
);
renderer.render().unwrap();
let body1 = std::fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
renderer.render().unwrap();
let body2 = std::fs::read_to_string(tmp.path().join("PROFILE.md")).unwrap();
assert_eq!(body1, body2, "second render should be idempotent");
}
#[test]
fn renders_dont_clobber_connected_accounts_block() {
let (cache, renderer, tmp) = make_renderer();
// Manually write a connected-accounts block first.
let ca_content = format!(
"{}\n## Connected Accounts\n\n- <!-- acct:gmail:c-1 --> **Gmail** (c-1): jane@test.com\n{}\n",
block_start("connected-accounts"),
block_end("connected-accounts"),
);
let profile_path = tmp.path().join("PROFILE.md");
std::fs::write(&profile_path, format!("# User Profile\n\n{ca_content}")).unwrap();
insert_facet(
&cache,
"style/verbosity",
"terse",
FacetState::Active,
UserState::Auto,
2.0,
);
renderer.render().unwrap();
let body = std::fs::read_to_string(&profile_path).unwrap();
// connected-accounts block preserved.
assert!(
body.contains("acct:gmail:c-1"),
"CA block clobbered:\n{body}"
);
assert!(
body.contains("jane@test.com"),
"CA block clobbered:\n{body}"
);
// Style block also written.
assert!(body.contains("terse"));
}
#[test]
fn renders_dont_touch_user_authored_text_outside_blocks() {
let (cache, renderer, tmp) = make_renderer();
let profile_path = tmp.path().join("PROFILE.md");
std::fs::write(
&profile_path,
"# User Profile\n\nHand-written note by the user.\n",
)
.unwrap();
insert_facet(
&cache,
"style/verbosity",
"terse",
FacetState::Active,
UserState::Auto,
2.0,
);
renderer.render().unwrap();
let body = std::fs::read_to_string(&profile_path).unwrap();
assert!(
body.contains("Hand-written note by the user."),
"user text lost:\n{body}"
);
assert!(body.contains("terse"));
}
#[test]
fn subscribes_and_handles_cache_rebuilt_event() {
// Verify that ProfileMdRenderer::subscribe compiles and returns a handle.
// Full async event delivery is tested in the integration test.
let tmp = TempDir::new().unwrap();
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(PROFILE_INIT_SQL).unwrap();
let cache = make_cache(Arc::new(Mutex::new(conn)));
let renderer = Arc::new(ProfileMdRenderer::new(cache, tmp.path().to_path_buf()));
// subscribe_global requires a running runtime; just verify the type works.
let _renderer_ref = Arc::clone(&renderer);
// We can't call subscribe_global in a unit test without a tokio runtime,
// but we verify the subscriber type implements EventHandler correctly.
let subscriber = RendererSubscriber(renderer);
assert_eq!(subscriber.name(), "learning::profile_md_renderer");
assert_eq!(subscriber.domains(), Some(["memory"].as_slice()));
}
}
+282
View File
@@ -2,6 +2,21 @@
//!
//! These sections read pre-fetched data from `PromptContext.learned` — no async
//! or blocking I/O happens during prompt building.
//!
//! ## Phase 3 addition (#566)
//!
//! [`load_learned_from_cache`] reads Active facets from the `FacetCache`
//! (backed by `user_profile_facets`) and returns them as a list of formatted
//! strings suitable for injection into `LearnedContextData.user_profile`.
//!
//! The existing KV-namespace reads in `fetch_learned_context` are preserved
//! (both paths active in this phase; KV path will be removed in a follow-up).
//!
//! ## Phase 4 addition (#566)
//!
//! [`MemoryAccessSection`] — a static prompt section that instructs the agent to
//! call `memory_recall` / `memory_search` before answering questions that draw on
//! prior sessions. Registered after `LearnedContextSection` in the section chain.
use crate::openhuman::context::prompt::{PromptContext, PromptSection};
use anyhow::Result;
@@ -82,6 +97,124 @@ impl PromptSection for UserProfileSection {
}
}
// ── MemoryAccessSection ───────────────────────────────────────────────────────
/// Static bias instruction that tells the agent to call `memory_recall` before
/// answering questions involving named people, projects, prior decisions, or
/// anything the user mentioned in past sessions.
///
/// The text is frozen at compile time — no I/O at build time.
/// Register this section after [`LearnedContextSection`] in the prompt-section
/// composition order (see `SystemPromptBuilder::with_defaults`).
pub struct MemoryAccessSection;
/// The static prose injected into every system prompt. Kept at ≤ 80 tokens.
pub const MEMORY_ACCESS_INSTRUCTION: &str = "\
## Memory access\n\
\n\
Before answering questions involving named people, projects, threads, prior \
decisions, recurring topics, or anything the user has mentioned in past sessions, \
call `memory_recall` (or `memory_search` for keyword lookups) to retrieve \
relevant context. Surface what matters in your reply; don't stitch together \
continuity from prompt history alone. Skip retrieval for purely procedural \
requests where prior context isn't relevant.";
impl PromptSection for MemoryAccessSection {
fn name(&self) -> &str {
"memory_access"
}
fn build(&self, _ctx: &PromptContext<'_>) -> Result<String> {
Ok(MEMORY_ACCESS_INSTRUCTION.to_string())
}
}
// ── Cache-backed loader ───────────────────────────────────────────────────────
/// Maximum number of facets to include in the ambient prompt injection.
///
/// Corresponds to "~25 entries total" from the Phase 3 spec.
const CACHE_PROMPT_CAP: usize = 25;
/// Load Active facets from the `FacetCache` and format them for prompt injection.
///
/// Returns a list of strings in the form `class/key: value`, sorted by stability
/// descending within each class, then alphabetically by class. The total is capped
/// at [`CACHE_PROMPT_CAP`] entries.
///
/// This function is **synchronous** and performs only SQLite reads — safe to call
/// from the synchronous part of the system prompt build path. The caller should
/// keep both this path and the existing KV-namespace path active until the KV path
/// is removed in a follow-up phase.
pub fn load_learned_from_cache(
cache: &crate::openhuman::learning::cache::FacetCache,
) -> Vec<String> {
let facets = match cache.list_active() {
Ok(f) => f,
Err(e) => {
tracing::warn!("[learning::prompt] load_learned_from_cache failed: {e}");
return Vec::new();
}
};
if facets.is_empty() {
return Vec::new();
}
// Group by class prefix (portion before the first '/'), then sort within
// each class by stability descending, then by key alphabetically.
use crate::openhuman::memory::store::profile::ProfileFacet;
use std::collections::BTreeMap;
let mut by_class: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (idx, f) in facets.iter().enumerate() {
let class = f
.key
.split_once('/')
.map(|(prefix, _rest)| prefix.to_string())
.unwrap_or_else(|| "other".to_string());
by_class.entry(class).or_default().push(idx);
}
for indices in by_class.values_mut() {
indices.sort_by(|&a, &b| {
facets[b]
.stability
.partial_cmp(&facets[a].stability)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| facets[a].key.cmp(&facets[b].key))
});
}
let mut result = Vec::with_capacity(CACHE_PROMPT_CAP);
'outer: for indices in by_class.values() {
for &idx in indices {
if result.len() >= CACHE_PROMPT_CAP {
break 'outer;
}
let f: &ProfileFacet = &facets[idx];
// Phase 4: render in structured `class/key: value` form so the
// agent can parse the source. Goal class keeps value-only (full
// sentence, no key prefix). Pinned entries get a trailing suffix.
let pinned =
if f.user_state == crate::openhuman::memory::store::profile::UserState::Pinned {
" *(pinned)*"
} else {
""
};
let entry = if f.key.starts_with("goal/") {
// Goal class: render just the value, it's a sentence.
format!("{}{}", f.value, pinned)
} else {
format!("**{}**: {}{}", f.key, f.value, pinned)
};
result.push(entry);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
@@ -233,4 +366,153 @@ mod tests {
.unwrap()
.is_empty());
}
// ── load_learned_from_cache ───────────────────────────────────────────────
#[test]
fn load_learned_from_cache_formats_active_facets() {
use crate::openhuman::learning::cache::FacetCache;
use crate::openhuman::memory::store::profile::{
FacetState, FacetType, ProfileFacet, UserState, PROFILE_INIT_SQL,
};
use parking_lot::Mutex;
use rusqlite::Connection;
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(PROFILE_INIT_SQL).unwrap();
let cache = FacetCache::new(Arc::new(Mutex::new(conn)));
let make_facet = |id: &str, key: &str, value: &str, stab: f64| ProfileFacet {
facet_id: id.into(),
facet_type: FacetType::Preference,
key: key.into(),
value: value.into(),
confidence: 0.8,
evidence_count: 2,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1200.0,
state: FacetState::Active,
stability: stab,
user_state: UserState::Auto,
evidence_refs: vec![],
class: None,
cue_families: None,
};
cache
.upsert(&make_facet("f1", "style/verbosity", "terse", 2.0))
.unwrap();
cache
.upsert(&make_facet("f2", "identity/name", "Alice", 1.8))
.unwrap();
cache
.upsert(&make_facet(
"f3",
"goal/learn_rust",
"Learn Rust this year",
1.6,
))
.unwrap();
// Provisional — should NOT appear.
let mut prov = make_facet("f4", "style/tone", "formal", 0.8);
prov.state = FacetState::Provisional;
cache.upsert(&prov).unwrap();
let result = load_learned_from_cache(&cache);
assert!(
!result.is_empty(),
"should produce entries for Active facets"
);
// Phase 4 format: "**style/verbosity**: terse"
assert!(
result.iter().any(|s| s.contains("style/verbosity")),
"style/verbosity should appear"
);
assert!(
result
.iter()
.any(|s| s.contains("**style/verbosity**: terse")),
"style/verbosity should use Phase 4 bold format"
);
// Goal class → value only (no key prefix)
assert!(
result.iter().any(|s| s == "Learn Rust this year"),
"goal class should render value only"
);
// Provisional should not appear
assert!(
!result.iter().any(|s| s.contains("style/tone")),
"provisional facet must not appear in cache prompt"
);
}
#[test]
fn load_learned_from_cache_empty_when_no_active_facets() {
use crate::openhuman::learning::cache::FacetCache;
use crate::openhuman::memory::store::profile::PROFILE_INIT_SQL;
use parking_lot::Mutex;
use rusqlite::Connection;
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(PROFILE_INIT_SQL).unwrap();
let cache = FacetCache::new(Arc::new(Mutex::new(conn)));
let result = load_learned_from_cache(&cache);
assert!(result.is_empty());
}
// ── MemoryAccessSection ───────────────────────────────────────────────────
#[test]
fn memory_access_section_renders_static_text() {
let section = MemoryAccessSection;
assert_eq!(section.name(), "memory_access");
let rendered = section
.build(&prompt_context(LearnedContextData::default()))
.unwrap();
assert!(
rendered.contains("## Memory access"),
"heading missing:\n{rendered}"
);
assert!(
rendered.contains("memory_recall"),
"memory_recall tool not mentioned:\n{rendered}"
);
assert!(
rendered.contains("memory_search"),
"memory_search tool not mentioned:\n{rendered}"
);
// Verify the rendered text matches the constant.
assert_eq!(rendered.trim(), MEMORY_ACCESS_INSTRUCTION.trim());
}
#[test]
fn memory_access_section_present_in_system_prompt_compose() {
// Verify the section renders correctly when added to a prompt composition.
let section = MemoryAccessSection;
let rendered = section
.build(&prompt_context(LearnedContextData::default()))
.unwrap();
// Spot-check the content constraint: ≤ 80 tokens (rough word count).
let word_count = rendered.split_whitespace().count();
assert!(
word_count <= 100,
"MemoryAccessSection is too long ({word_count} words, target ≤ 80 tokens)"
);
// The section name must be stable (used for insert_section_before).
assert_eq!(section.name(), "memory_access");
// Content check: the section must mention both retrieval tools.
assert!(rendered.contains("memory_recall"));
assert!(rendered.contains("memory_search"));
// Verify it is non-empty for any PromptContext (not context-gated).
let empty_ctx = prompt_context(LearnedContextData::default());
let rendered_empty_ctx = section.build(&empty_ctx).unwrap();
assert!(
!rendered_empty_ctx.trim().is_empty(),
"must render regardless of learned context"
);
}
}
+129 -2
View File
@@ -6,12 +6,14 @@
use crate::openhuman::agent::hooks::{PostTurnHook, TurnContext};
use crate::openhuman::config::{Config, LearningConfig, ReflectionSource};
use crate::openhuman::learning::candidate::{self, CueFamily, EvidenceRef, FacetClass};
use crate::openhuman::memory::{Memory, MemoryCategory};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
/// Memory namespace + custom-category tag for explicit user reflections.
///
@@ -285,6 +287,105 @@ impl ReflectionHook {
Ok(())
}
/// Emit [`LearningCandidate`]s into the global buffer from a completed
/// reflection output. This runs after the existing KV memory writes so
/// backward compatibility is preserved.
///
/// - Heuristic cues → `Goal` candidates (Explicit, confidence 0.85)
/// - LLM `user_preferences` entries → `Style` candidates (Behavioral, 0.7)
/// - LLM `user_reflections` entries → `Goal` candidates (Behavioral, 0.7)
fn emit_candidates_from_reflection(output: &ReflectionOutput, episodic_id: i64) {
let now = now_secs();
let buf = candidate::global();
let mut count = 0usize;
// user_preferences — coarse style candidates.
for (n, pref) in output.user_preferences.iter().enumerate() {
let pref = pref.trim();
if pref.is_empty() {
continue;
}
// Attempt cheap key=value parse first ("verbosity=terse").
let (key, value) = if let Some(eq_pos) = pref.find('=') {
let k = pref[..eq_pos].trim().to_string();
let v = pref[eq_pos + 1..].trim().to_string();
if !k.is_empty() && !v.is_empty() {
(k, v)
} else {
(format!("raw_pref_{n}"), pref.to_string())
}
} else {
(format!("raw_pref_{n}"), pref.to_string())
};
buf.push(crate::openhuman::learning::candidate::LearningCandidate {
class: FacetClass::Style,
key,
value,
cue_family: CueFamily::Behavioral,
evidence: EvidenceRef::Episodic { episodic_id },
initial_confidence: 0.70,
observed_at: now,
});
count += 1;
}
// user_reflections — explicit user self-statements, map to Goal.
for reflection in &output.user_reflections {
let trimmed = reflection.trim();
if trimmed.is_empty() {
continue;
}
let key = slugify_candidate(trimmed);
buf.push(crate::openhuman::learning::candidate::LearningCandidate {
class: FacetClass::Goal,
key,
value: trimmed.to_string(),
cue_family: CueFamily::Behavioral,
evidence: EvidenceRef::Episodic { episodic_id },
initial_confidence: 0.70,
observed_at: now,
});
count += 1;
}
if count > 0 {
log::debug!(
"[learning::reflection] emitted {} candidate(s) to buffer from reflection output",
count
);
}
}
/// Emit Goal candidates into the global buffer from heuristic cue sentences.
fn emit_candidates_from_cues(cues: &[String], episodic_id: i64) {
if cues.is_empty() {
return;
}
let now = now_secs();
let buf = candidate::global();
for cue in cues {
let trimmed = cue.trim();
if trimmed.is_empty() {
continue;
}
let key = slugify_candidate(trimmed);
buf.push(crate::openhuman::learning::candidate::LearningCandidate {
class: FacetClass::Goal,
key,
value: trimmed.to_string(),
cue_family: CueFamily::Explicit,
evidence: EvidenceRef::Episodic { episodic_id },
initial_confidence: 0.85,
observed_at: now,
});
}
log::debug!(
"[learning::reflection] emitted {} heuristic cue candidate(s) to buffer",
cues.len()
);
}
/// Persist a single reflection sentence into the dedicated namespace.
/// Public to the crate so the heuristic fast-path can reuse the same
/// storage shape without going through the LLM round-trip.
@@ -423,9 +524,16 @@ impl PostTurnHook for ReflectionHook {
// reflections like "remember that I prefer terse answers" are
// promoted to the privileged reflection namespace without paying
// for a reflection-LLM round-trip.
//
// Also emits Goal candidates to the learning buffer (Phase 2).
if self.config.enabled {
for cue in extract_reflection_cues(&ctx.user_message) {
if let Err(e) = self.persist_reflection_deduped(&cue, &mut seen).await {
let cues = extract_reflection_cues(&ctx.user_message);
// Emit candidates before the KV persist so even a failed persist
// doesn't prevent the buffer from seeing the signal.
let episodic_id = crate::openhuman::learning::candidate::global().len() as i64;
Self::emit_candidates_from_cues(&cues, episodic_id);
for cue in &cues {
if let Err(e) = self.persist_reflection_deduped(cue, &mut seen).await {
log::warn!("[learning] failed to persist heuristic reflection: {e}");
}
}
@@ -480,6 +588,12 @@ impl PostTurnHook for ReflectionHook {
return Err(e);
}
// Emit learning candidates from LLM output into the global buffer (Phase 2).
// Use the current buffer length as a synthetic episodic_id proxy — Phase 3
// will replace this with a real episodic_log row id.
let episodic_id = crate::openhuman::learning::candidate::global().len() as i64;
Self::emit_candidates_from_reflection(&output, episodic_id);
// Persist LLM-extracted reflections through the shared dedupe
// set so any sentence the heuristic already captured above is
// not written twice. Failures here are logged but never roll
@@ -520,6 +634,19 @@ fn slugify(s: &str) -> String {
.collect()
}
/// Slugify for candidate keys — same logic as `slugify` but named distinctly to
/// clarify the caller's intent.
fn slugify_candidate(s: &str) -> String {
slugify(s)
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
#[cfg(test)]
#[path = "reflection_tests.rs"]
mod tests;
@@ -439,3 +439,81 @@ async fn on_turn_complete_rolls_back_counter_when_reflection_call_fails() {
0
);
}
#[tokio::test]
async fn on_turn_complete_emits_candidates_to_buffer_for_heuristic_cues() {
use crate::openhuman::learning::candidate::{self, FacetClass};
let memory: Arc<dyn Memory> = Arc::new(MockMemory::default());
// Use local reflection source with complexity gated high so only the heuristic
// fast-path runs (no LLM round-trip needed).
let mut cfg = reflection_config();
cfg.min_turn_complexity = 99;
cfg.reflection_source = ReflectionSource::Local;
let hook = ReflectionHook::new(cfg, Arc::new(Config::default()), memory, None);
// Snapshot the buffer before the call.
let before = candidate::global().len();
let turn = TurnContext {
user_message: "Going forward I want concise replies only.".into(),
assistant_response: "ok".into(),
tool_calls: Vec::new(),
turn_duration_ms: 10,
session_id: Some("buffer-test".into()),
iteration_count: 1,
};
hook.on_turn_complete(&turn).await.unwrap();
let after = candidate::global().peek();
let new_candidates: Vec<_> = after.into_iter().skip(before).collect();
assert!(
!new_candidates.is_empty(),
"on_turn_complete should push at least one candidate to the buffer for heuristic cues"
);
assert!(
new_candidates.iter().any(|c| c.class == FacetClass::Goal),
"heuristic cues should produce Goal candidates"
);
}
#[tokio::test]
async fn on_turn_complete_emits_style_candidates_from_llm_preferences() {
use crate::openhuman::learning::candidate::{self, FacetClass};
use crate::openhuman::providers::Provider;
struct StubPrefProvider;
#[async_trait]
impl Provider for StubPrefProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> anyhow::Result<String> {
Ok(r#"{"observations":[],"patterns":[],"user_preferences":["verbosity=terse"],"user_reflections":[]}"#.into())
}
}
let memory: Arc<dyn Memory> = Arc::new(MockMemory::default());
let hook = ReflectionHook::new(
reflection_config(),
Arc::new(Config::default()),
memory,
Some(Arc::new(StubPrefProvider)),
);
let before = candidate::global().len();
hook.on_turn_complete(&reflective_turn()).await.unwrap();
let all = candidate::global().peek();
let new_candidates: Vec<_> = all.into_iter().skip(before).collect();
assert!(
new_candidates
.iter()
.any(|c| c.class == FacetClass::Style && c.key == "verbosity"),
"user_preferences with key=value format should produce a Style candidate"
);
}
+164
View File
@@ -0,0 +1,164 @@
//! Background scheduler for the stability detector rebuild cycle.
//!
//! Provides two scheduling mechanisms:
//!
//! 1. **Periodic timer** — `spawn_rebuild_loop` runs a rebuild every `interval`
//! (default 30 minutes).
//!
//! 2. **Event-driven trigger** — `spawn_event_driven_trigger` subscribes to
//! [`DomainEvent::DocumentCanonicalized`] and [`DomainEvent::TreeSummarizerPropagated`]
//! events and schedules a debounced out-of-cycle rebuild 60 seconds after receipt.
//!
//! Both mechanisms respect a shutdown signal from a
//! `tokio::sync::watch::Receiver<bool>` channel.
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use tokio::sync::watch;
use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle};
// Arc imported for detector sharing across async tasks
use crate::openhuman::learning::stability_detector::StabilityDetector;
// ── Default intervals ─────────────────────────────────────────────────────────
/// Default periodic rebuild interval.
pub const DEFAULT_REBUILD_INTERVAL: Duration = Duration::from_secs(30 * 60);
/// Debounce delay for event-driven rebuilds — we wait this long after the last
/// triggering event before running the rebuild.
const EVENT_REBUILD_DELAY: Duration = Duration::from_secs(60);
// ── Periodic rebuild loop ─────────────────────────────────────────────────────
/// Spawn a background task that runs a stability detector rebuild every `interval`.
///
/// The task shuts down when `shutdown_rx` signals `true` or when the tokio
/// runtime exits.
///
/// # Arguments
///
/// * `detector` — the rebuild engine to call.
/// * `interval` — how often to rebuild (default: [`DEFAULT_REBUILD_INTERVAL`]).
/// * `shutdown_rx` — watch channel; the task exits when the value becomes `true`.
pub fn spawn_rebuild_loop(
detector: Arc<StabilityDetector>,
interval: Duration,
mut shutdown_rx: watch::Receiver<bool>,
) {
tracing::info!(
"[learning::scheduler] starting periodic rebuild loop (interval={}s)",
interval.as_secs()
);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// Skip the first immediate tick so we don't rebuild at startup before
// any producers have had a chance to emit candidates.
ticker.tick().await;
loop {
tokio::select! {
_ = ticker.tick() => {
run_rebuild_logged(&detector, "periodic").await;
}
_ = shutdown_rx.changed() => {
if *shutdown_rx.borrow() {
tracing::info!("[learning::scheduler] shutdown signal received, stopping rebuild loop");
break;
}
}
}
}
tracing::info!("[learning::scheduler] rebuild loop stopped");
});
}
// ── Event-driven trigger ──────────────────────────────────────────────────────
/// Event handler that schedules a debounced rebuild on relevant domain events.
///
/// Listens for:
/// - [`DomainEvent::DocumentCanonicalized`] with `source_kind == "email"` or `"document"`
/// - [`DomainEvent::TreeSummarizerPropagated`] (tree summariser flush signal)
struct RebuildTriggerHandler {
detector: Arc<StabilityDetector>,
}
#[async_trait]
impl EventHandler for RebuildTriggerHandler {
fn name(&self) -> &str {
"learning::rebuild_trigger"
}
fn domains(&self) -> Option<&[&str]> {
Some(&["memory", "tree_summarizer"])
}
async fn handle(&self, event: &DomainEvent) {
let should_trigger = match event {
DomainEvent::DocumentCanonicalized { source_kind, .. } => {
matches!(source_kind.as_str(), "email" | "document")
}
DomainEvent::TreeSummarizerPropagated { .. } => true,
_ => false,
};
if !should_trigger {
return;
}
tracing::debug!(
"[learning::scheduler] event-driven rebuild triggered, scheduling in {}s",
EVENT_REBUILD_DELAY.as_secs()
);
let detector = Arc::clone(&self.detector);
let delay = EVENT_REBUILD_DELAY;
tokio::spawn(async move {
tokio::time::sleep(delay).await;
run_rebuild_logged(&detector, "event-driven").await;
});
}
}
/// Register the event-driven trigger subscriber.
///
/// The returned `SubscriptionHandle` must be kept alive for the subscription to
/// remain active. Callers should store it in a static `OnceLock` (same pattern
/// as the `EmailSignatureSubscriber`).
pub fn register_event_trigger(detector: Arc<StabilityDetector>) -> Option<SubscriptionHandle> {
subscribe_global(Arc::new(RebuildTriggerHandler { detector }))
}
// ── Shared rebuild runner ─────────────────────────────────────────────────────
async fn run_rebuild_logged(detector: &StabilityDetector, source: &str) {
let now = now_secs();
match detector.rebuild(now) {
Ok(outcome) => {
tracing::info!(
"[learning::scheduler] {source} rebuild complete: \
added={} evicted={} kept={} total={}",
outcome.added,
outcome.evicted,
outcome.kept,
outcome.total_size,
);
}
Err(e) => {
tracing::warn!("[learning::scheduler] {source} rebuild failed (non-fatal): {e}");
}
}
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
+815 -4
View File
@@ -11,6 +11,15 @@ pub fn all_learning_controller_schemas() -> Vec<ControllerSchema> {
vec![
learning_schemas("learning_linkedin_enrichment"),
learning_schemas("learning_save_profile"),
learning_schemas("learning_rebuild_cache"),
learning_schemas("learning_cache_stats"),
learning_schemas("learning_list_facets"),
learning_schemas("learning_get_facet"),
learning_schemas("learning_update_facet"),
learning_schemas("learning_pin_facet"),
learning_schemas("learning_unpin_facet"),
learning_schemas("learning_forget_facet"),
learning_schemas("learning_reset_cache"),
]
}
@@ -24,6 +33,42 @@ pub fn all_learning_registered_controllers() -> Vec<RegisteredController> {
schema: learning_schemas("learning_save_profile"),
handler: handle_save_profile,
},
RegisteredController {
schema: learning_schemas("learning_rebuild_cache"),
handler: handle_rebuild_cache,
},
RegisteredController {
schema: learning_schemas("learning_cache_stats"),
handler: handle_cache_stats,
},
RegisteredController {
schema: learning_schemas("learning_list_facets"),
handler: handle_list_facets,
},
RegisteredController {
schema: learning_schemas("learning_get_facet"),
handler: handle_get_facet,
},
RegisteredController {
schema: learning_schemas("learning_update_facet"),
handler: handle_update_facet,
},
RegisteredController {
schema: learning_schemas("learning_pin_facet"),
handler: handle_pin_facet,
},
RegisteredController {
schema: learning_schemas("learning_unpin_facet"),
handler: handle_unpin_facet,
},
RegisteredController {
schema: learning_schemas("learning_forget_facet"),
handler: handle_forget_facet,
},
RegisteredController {
schema: learning_schemas("learning_reset_cache"),
handler: handle_reset_cache,
},
]
}
@@ -99,6 +144,280 @@ pub fn learning_schemas(function: &str) -> ControllerSchema {
},
],
},
"learning_rebuild_cache" => ControllerSchema {
namespace: "learning",
function: "rebuild_cache",
description: "Manually trigger a stability-detector rebuild cycle. \
Drains the candidate buffer, scores all (class, key) pairs, \
applies class budgets, and persists the updated ambient cache. \
Returns rebuild statistics.",
inputs: vec![],
outputs: vec![
FieldSchema {
name: "added",
ty: TypeSchema::U64,
comment: "Facet rows newly created in this cycle.",
required: true,
},
FieldSchema {
name: "evicted",
ty: TypeSchema::U64,
comment: "Facet rows demoted to Dropped or deleted.",
required: true,
},
FieldSchema {
name: "kept",
ty: TypeSchema::U64,
comment: "Facet rows carried over unchanged.",
required: true,
},
FieldSchema {
name: "total_size",
ty: TypeSchema::U64,
comment: "Total Active rows after the rebuild.",
required: true,
},
],
},
"learning_cache_stats" => ControllerSchema {
namespace: "learning",
function: "cache_stats",
description: "Return current ambient cache statistics — total row count, \
per-state breakdown, and per-class breakdown.",
inputs: vec![],
outputs: vec![
FieldSchema {
name: "total",
ty: TypeSchema::U64,
comment: "Total rows in the cache (all states).",
required: true,
},
FieldSchema {
name: "active",
ty: TypeSchema::U64,
comment: "Rows with state=active.",
required: true,
},
FieldSchema {
name: "provisional",
ty: TypeSchema::U64,
comment: "Rows with state=provisional.",
required: true,
},
FieldSchema {
name: "candidate",
ty: TypeSchema::U64,
comment: "Rows with state=candidate.",
required: true,
},
FieldSchema {
name: "dropped",
ty: TypeSchema::U64,
comment: "Rows with state=dropped.",
required: true,
},
FieldSchema {
name: "by_class",
ty: TypeSchema::Json,
comment: "Map of class name → row count (active rows only).",
required: true,
},
],
},
"learning_list_facets" => ControllerSchema {
namespace: "learning",
function: "list_facets",
description: "List all facets in the ambient cache (active + provisional). \
Optionally filter by class.",
inputs: vec![FieldSchema {
name: "class",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment:
"Optional class filter: style | identity | tooling | veto | goal | channel.",
required: false,
}],
outputs: vec![
FieldSchema {
name: "facets",
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
comment:
"Array of facet objects with key, value, state, user_state, stability.",
required: true,
},
FieldSchema {
name: "count",
ty: TypeSchema::U64,
comment: "Total number of facets returned.",
required: true,
},
],
},
"learning_get_facet" => ControllerSchema {
namespace: "learning",
function: "get_facet",
description:
"Fetch a single facet by class and key suffix (e.g. class=style, key=verbosity).",
inputs: vec![
FieldSchema {
name: "class",
ty: TypeSchema::String,
comment: "Facet class: style | identity | tooling | veto | goal | channel.",
required: true,
},
FieldSchema {
name: "key",
ty: TypeSchema::String,
comment:
"Key suffix within the class (e.g. \"verbosity\" for style/verbosity).",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "facet",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "The facet object, or null if not found.",
required: false,
},
FieldSchema {
name: "found",
ty: TypeSchema::Bool,
comment: "Whether the facet was found.",
required: true,
},
],
},
"learning_update_facet" => ControllerSchema {
namespace: "learning",
function: "update_facet",
description: "Update the value of an existing facet and pin it (user_state=Pinned). \
Returns the updated facet.",
inputs: vec![
FieldSchema {
name: "class",
ty: TypeSchema::String,
comment: "Facet class: style | identity | tooling | veto | goal | channel.",
required: true,
},
FieldSchema {
name: "key",
ty: TypeSchema::String,
comment: "Key suffix within the class.",
required: true,
},
FieldSchema {
name: "value",
ty: TypeSchema::String,
comment: "New value to set.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "facet",
ty: TypeSchema::Json,
comment: "The updated facet object.",
required: true,
}],
},
"learning_pin_facet" => ControllerSchema {
namespace: "learning",
function: "pin_facet",
description:
"Pin a facet (user_state=Pinned): locks Active regardless of stability score.",
inputs: vec![
FieldSchema {
name: "class",
ty: TypeSchema::String,
comment: "Facet class.",
required: true,
},
FieldSchema {
name: "key",
ty: TypeSchema::String,
comment: "Key suffix within the class.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "facet",
ty: TypeSchema::Json,
comment: "The updated facet object.",
required: true,
}],
},
"learning_unpin_facet" => ControllerSchema {
namespace: "learning",
function: "unpin_facet",
description:
"Unpin a facet (user_state=Auto): returns stability management to the detector.",
inputs: vec![
FieldSchema {
name: "class",
ty: TypeSchema::String,
comment: "Facet class.",
required: true,
},
FieldSchema {
name: "key",
ty: TypeSchema::String,
comment: "Key suffix within the class.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "facet",
ty: TypeSchema::Json,
comment: "The updated facet object.",
required: true,
}],
},
"learning_forget_facet" => ControllerSchema {
namespace: "learning",
function: "forget_facet",
description:
"Forget a facet (user_state=Forgotten): locks Dropped and blocks re-promotion.",
inputs: vec![
FieldSchema {
name: "class",
ty: TypeSchema::String,
comment: "Facet class.",
required: true,
},
FieldSchema {
name: "key",
ty: TypeSchema::String,
comment: "Key suffix within the class.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "facet",
ty: TypeSchema::Option(Box::new(TypeSchema::Json)),
comment: "The facet in its Dropped state, or null if it didn't exist.",
required: false,
}],
},
"learning_reset_cache" => ControllerSchema {
namespace: "learning",
function: "reset_cache",
description: "Reset the ambient cache: delete all Auto rows, preserve Pinned rows. \
The next rebuild repopulates from the substrate.",
inputs: vec![],
outputs: vec![
FieldSchema {
name: "deleted",
ty: TypeSchema::U64,
comment: "Number of Auto rows deleted.",
required: true,
},
FieldSchema {
name: "pinned_preserved",
ty: TypeSchema::U64,
comment: "Number of Pinned rows kept.",
required: true,
},
],
},
_ => ControllerSchema {
namespace: "learning",
function: "unknown",
@@ -119,13 +438,13 @@ mod tests {
use super::*;
#[test]
fn all_schemas_returns_two() {
assert_eq!(all_learning_controller_schemas().len(), 2);
fn all_schemas_returns_eleven() {
assert_eq!(all_learning_controller_schemas().len(), 11);
}
#[test]
fn all_controllers_returns_two() {
assert_eq!(all_learning_registered_controllers().len(), 2);
fn all_controllers_returns_eleven() {
assert_eq!(all_learning_registered_controllers().len(), 11);
}
#[test]
@@ -162,6 +481,57 @@ mod tests {
let c = all_learning_registered_controllers();
assert_eq!(s[0].function, c[0].schema.function);
}
#[test]
fn list_facets_schema_shape() {
let s = learning_schemas("learning_list_facets");
assert_eq!(s.namespace, "learning");
assert_eq!(s.function, "list_facets");
assert!(s.inputs.iter().any(|f| f.name == "class" && !f.required));
assert!(s.outputs.iter().any(|f| f.name == "facets"));
assert!(s.outputs.iter().any(|f| f.name == "count"));
}
#[test]
fn get_facet_schema_shape() {
let s = learning_schemas("learning_get_facet");
assert_eq!(s.function, "get_facet");
assert!(s.inputs.iter().any(|f| f.name == "class" && f.required));
assert!(s.inputs.iter().any(|f| f.name == "key" && f.required));
}
#[test]
fn update_facet_schema_shape() {
let s = learning_schemas("learning_update_facet");
assert_eq!(s.function, "update_facet");
assert!(s.inputs.iter().any(|f| f.name == "value" && f.required));
}
#[test]
fn pin_facet_schema_shape() {
let s = learning_schemas("learning_pin_facet");
assert_eq!(s.function, "pin_facet");
}
#[test]
fn unpin_facet_schema_shape() {
let s = learning_schemas("learning_unpin_facet");
assert_eq!(s.function, "unpin_facet");
}
#[test]
fn forget_facet_schema_shape() {
let s = learning_schemas("learning_forget_facet");
assert_eq!(s.function, "forget_facet");
}
#[test]
fn reset_cache_schema_shape() {
let s = learning_schemas("learning_reset_cache");
assert_eq!(s.function, "reset_cache");
assert!(s.outputs.iter().any(|f| f.name == "deleted"));
assert!(s.outputs.iter().any(|f| f.name == "pinned_preserved"));
}
}
fn handle_linkedin_enrichment(params: Map<String, Value>) -> ControllerFuture {
@@ -231,3 +601,444 @@ fn handle_save_profile(params: Map<String, Value>) -> ControllerFuture {
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
fn handle_rebuild_cache(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
use crate::openhuman::learning::cache::FacetCache;
use crate::openhuman::learning::stability_detector::StabilityDetector;
use std::time::{SystemTime, UNIX_EPOCH};
tracing::debug!("[learning.rebuild_cache] manual rebuild requested via RPC");
let client = crate::openhuman::memory::global::client_if_ready()
.ok_or_else(|| "memory client not ready".to_string())?;
let conn = client.profile_conn();
let cache = FacetCache::new(conn);
let detector = StabilityDetector::new(cache);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
let outcome = detector
.rebuild(now)
.map_err(|e| format!("rebuild failed: {e:#}"))?;
let log = vec![format!(
"learning.rebuild_cache: added={} evicted={} kept={} total={}",
outcome.added, outcome.evicted, outcome.kept, outcome.total_size,
)];
let payload = serde_json::json!({
"added": outcome.added,
"evicted": outcome.evicted,
"kept": outcome.kept,
"total_size": outcome.total_size,
});
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
fn handle_cache_stats(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
use crate::openhuman::learning::cache::FacetCache;
use crate::openhuman::memory::store::profile::FacetState;
tracing::debug!("[learning.cache_stats] cache stats requested via RPC");
let client = crate::openhuman::memory::global::client_if_ready()
.ok_or_else(|| "memory client not ready".to_string())?;
let conn = client.profile_conn();
let cache = FacetCache::new(conn);
let all_facets = cache
.list_all()
.map_err(|e| format!("list_all failed: {e:#}"))?;
let total = all_facets.len();
let active = all_facets
.iter()
.filter(|f| f.state == FacetState::Active)
.count();
let provisional = all_facets
.iter()
.filter(|f| f.state == FacetState::Provisional)
.count();
let candidate = all_facets
.iter()
.filter(|f| f.state == FacetState::Candidate)
.count();
let dropped = all_facets
.iter()
.filter(|f| f.state == FacetState::Dropped)
.count();
// Per-class count (Active rows only, keyed by class field or key prefix).
let mut by_class: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for f in all_facets.iter().filter(|f| f.state == FacetState::Active) {
let cls = f
.class
.clone()
.or_else(|| f.key.split_once('/').map(|(p, _)| p.to_string()))
.unwrap_or_else(|| "_other".to_string());
*by_class.entry(cls).or_insert(0) += 1;
}
let log = vec![format!(
"learning.cache_stats: total={total} active={active} provisional={provisional} \
candidate={candidate} dropped={dropped}"
)];
let payload = serde_json::json!({
"total": total,
"active": active,
"provisional": provisional,
"candidate": candidate,
"dropped": dropped,
"by_class": by_class,
});
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
// ── Helper: shared cache access ───────────────────────────────────────────────
/// Build a [`FacetCache`] from the global memory client, or return a string error.
fn get_cache() -> Result<crate::openhuman::learning::cache::FacetCache, String> {
let client = crate::openhuman::memory::global::client_if_ready()
.ok_or_else(|| "memory client not ready".to_string())?;
Ok(crate::openhuman::learning::cache::FacetCache::new(
client.profile_conn(),
))
}
/// Build the full facet key from class string + key suffix.
/// E.g. (`"style"`, `"verbosity"`) → `"style/verbosity"`.
fn full_key(class_str: &str, key_suffix: &str) -> String {
format!("{class_str}/{key_suffix}")
}
/// Serialize a [`ProfileFacet`] to a serde_json [`Value`] for RPC output.
fn facet_to_json(f: &crate::openhuman::memory::store::profile::ProfileFacet) -> serde_json::Value {
serde_json::json!({
"key": f.key,
"value": f.value,
"state": f.state.as_str(),
"user_state": f.user_state.as_str(),
"stability": f.stability,
"confidence": f.confidence,
"evidence_count": f.evidence_count,
"first_seen_at": f.first_seen_at,
"last_seen_at": f.last_seen_at,
"class": f.class,
})
}
// ── list_facets ───────────────────────────────────────────────────────────────
fn handle_list_facets(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
use crate::openhuman::memory::store::profile::FacetState;
tracing::debug!("[learning.list_facets] called");
let class_filter = params
.get("class")
.and_then(Value::as_str)
.map(str::to_string);
let cache = get_cache()?;
// list_all returns all states (active + provisional + candidate + dropped).
let all = cache
.list_all()
.map_err(|e| format!("list_all failed: {e:#}"))?;
let facets: Vec<serde_json::Value> = all
.iter()
.filter(|f| {
// Expose Active and Provisional rows to the user.
f.state == FacetState::Active || f.state == FacetState::Provisional
})
.filter(|f| {
if let Some(cls) = &class_filter {
f.class.as_deref() == Some(cls.as_str())
|| f.key.starts_with(&format!("{cls}/"))
} else {
true
}
})
.map(facet_to_json)
.collect();
let count = facets.len();
let log = vec![format!(
"learning.list_facets: returned {count} facets (class_filter={:?})",
class_filter
)];
let payload = serde_json::json!({ "facets": facets, "count": count });
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
// ── get_facet ─────────────────────────────────────────────────────────────────
fn handle_get_facet(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let class_str = params
.get("class")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `class`".to_string())?
.to_string();
let key_suffix = params
.get("key")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `key`".to_string())?
.to_string();
let fk = full_key(&class_str, &key_suffix);
tracing::debug!("[learning.get_facet] key={fk}");
let cache = get_cache()?;
let facet = cache.get(&fk).map_err(|e| format!("get failed: {e:#}"))?;
let (found, facet_val) = match &facet {
Some(f) => (true, facet_to_json(f)),
None => (false, serde_json::Value::Null),
};
let log = vec![format!("learning.get_facet: key={fk} found={found}")];
let payload = serde_json::json!({ "facet": facet_val, "found": found });
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
// ── update_facet ──────────────────────────────────────────────────────────────
fn handle_update_facet(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
use crate::openhuman::memory::store::profile::UserState;
let class_str = params
.get("class")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `class`".to_string())?
.to_string();
let key_suffix = params
.get("key")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `key`".to_string())?
.to_string();
let new_value = params
.get("value")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `value`".to_string())?
.to_string();
let fk = full_key(&class_str, &key_suffix);
tracing::debug!("[learning.update_facet] key={fk} value={new_value}");
let cache = get_cache()?;
let mut facet = cache
.get(&fk)
.map_err(|e| format!("get failed: {e:#}"))?
.ok_or_else(|| format!("facet not found: {fk}"))?;
// Update value and pin so this survives future rebuilds.
facet.value = new_value.clone();
facet.user_state = UserState::Pinned;
cache
.upsert(&facet)
.map_err(|e| format!("upsert failed: {e:#}"))?;
let updated = cache
.get(&fk)
.map_err(|e| format!("re-read failed: {e:#}"))?
.ok_or_else(|| "facet disappeared after upsert".to_string())?;
let log = vec![format!(
"learning.update_facet: key={fk} new_value={new_value} user_state=pinned"
)];
let payload = serde_json::json!({ "facet": facet_to_json(&updated) });
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
// ── pin_facet ─────────────────────────────────────────────────────────────────
fn handle_pin_facet(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
use crate::openhuman::memory::store::profile::UserState;
let class_str = params
.get("class")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `class`".to_string())?
.to_string();
let key_suffix = params
.get("key")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `key`".to_string())?
.to_string();
let fk = full_key(&class_str, &key_suffix);
tracing::debug!("[learning.pin_facet] key={fk}");
let cache = get_cache()?;
let updated = cache
.set_user_state(&fk, UserState::Pinned)
.map_err(|e| format!("set_user_state failed: {e:#}"))?;
if !updated {
return Err(format!("facet not found: {fk}"));
}
let facet = cache
.get(&fk)
.map_err(|e| format!("re-read failed: {e:#}"))?
.ok_or_else(|| "facet disappeared after update".to_string())?;
let log = vec![format!("learning.pin_facet: key={fk} user_state=pinned")];
let payload = serde_json::json!({ "facet": facet_to_json(&facet) });
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
// ── unpin_facet ───────────────────────────────────────────────────────────────
fn handle_unpin_facet(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
use crate::openhuman::memory::store::profile::UserState;
let class_str = params
.get("class")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `class`".to_string())?
.to_string();
let key_suffix = params
.get("key")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `key`".to_string())?
.to_string();
let fk = full_key(&class_str, &key_suffix);
tracing::debug!("[learning.unpin_facet] key={fk}");
let cache = get_cache()?;
let updated = cache
.set_user_state(&fk, UserState::Auto)
.map_err(|e| format!("set_user_state failed: {e:#}"))?;
if !updated {
return Err(format!("facet not found: {fk}"));
}
let facet = cache
.get(&fk)
.map_err(|e| format!("re-read failed: {e:#}"))?
.ok_or_else(|| "facet disappeared after update".to_string())?;
let log = vec![format!("learning.unpin_facet: key={fk} user_state=auto")];
let payload = serde_json::json!({ "facet": facet_to_json(&facet) });
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
// ── forget_facet ──────────────────────────────────────────────────────────────
fn handle_forget_facet(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
use crate::openhuman::memory::store::profile::{FacetState, UserState};
let class_str = params
.get("class")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `class`".to_string())?
.to_string();
let key_suffix = params
.get("key")
.and_then(Value::as_str)
.ok_or_else(|| "missing required `key`".to_string())?
.to_string();
let fk = full_key(&class_str, &key_suffix);
tracing::debug!("[learning.forget_facet] key={fk}");
let cache = get_cache()?;
let facet_before = cache.get(&fk).map_err(|e| format!("get failed: {e:#}"))?;
let facet_json = if let Some(mut f) = facet_before {
// Mark Forgotten + Dropped so it doesn't resurface.
f.user_state = UserState::Forgotten;
f.state = FacetState::Dropped;
cache
.upsert(&f)
.map_err(|e| format!("upsert failed: {e:#}"))?;
let updated = cache
.get(&fk)
.map_err(|e| format!("re-read failed: {e:#}"))?
.unwrap_or(f);
facet_to_json(&updated)
} else {
serde_json::Value::Null
};
let log = vec![format!(
"learning.forget_facet: key={fk} state=dropped user_state=forgotten"
)];
let payload = serde_json::json!({ "facet": facet_json });
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
// ── reset_cache ───────────────────────────────────────────────────────────────
fn handle_reset_cache(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
use crate::openhuman::memory::store::profile::UserState;
tracing::debug!("[learning.reset_cache] called");
let cache = get_cache()?;
let all = cache
.list_all()
.map_err(|e| format!("list_all failed: {e:#}"))?;
let pinned_preserved = all
.iter()
.filter(|f| f.user_state == UserState::Pinned)
.count();
// Delete all non-Pinned rows.
let mut deleted = 0usize;
for f in &all {
if f.user_state != UserState::Pinned {
if cache.delete(&f.key).unwrap_or(false) {
deleted += 1;
}
}
}
tracing::info!(
"[learning.reset_cache] deleted={deleted} pinned_preserved={pinned_preserved}"
);
let log = vec![format!(
"learning.reset_cache: deleted={deleted} pinned_preserved={pinned_preserved}"
)];
let payload = serde_json::json!({
"deleted": deleted,
"pinned_preserved": pinned_preserved,
});
RpcOutcome::new(payload, log).into_cli_compatible_json()
})
}
@@ -0,0 +1,846 @@
//! Stability detector — Phase 3 of issue #566.
//!
//! Reads candidates from `candidate::global()`, aggregates them with existing
//! rows from `user_profile_facets`, scores each (class, key) pair by the
//! stability formula, resolves value conflicts, applies per-class budgets,
//! assigns lifecycle states, writes the result back to the table, and emits
//! [`DomainEvent::CacheRebuilt`].
//!
//! # Stability formula
//!
//! ```text
//! stability(class, key) = base × cue_mult × user_state_mult
//!
//! base = Σ(cue_family.weight() × exp(-Δt / half_life(class)) × ln(1 + evidence_count))
//! cue_mult = 2.0 if any contributing evidence has CueFamily::Explicit, else 1.0
//! user_state_mult = ∞ if Pinned, 0 if Forgotten, 1 otherwise
//! ```
//!
//! # Thresholds
//!
//! | Symbol | Value | Meaning |
//! |--------|-------|---------|
//! | τ_promote | 1.5 | Enter as Active |
//! | τ_provisional | 0.7 | Enter as Provisional |
//! | τ_evict | 0.4 | Keep as Candidate |
//! | < τ_evict | — | Dropped |
//!
//! # Class budgets
//!
//! After state assignment, each class retains at most `class_budget(class)` Active
//! rows by stability. Excess Active rows are demoted to Provisional. A cross-class
//! overflow pool holds up to `BUDGET_OVERFLOW` extra Provisional rows.
use std::collections::HashMap;
use std::sync::Arc;
use crate::core::event_bus;
use crate::core::event_bus::DomainEvent;
use crate::openhuman::learning::cache::FacetCache;
use crate::openhuman::learning::candidate::{self, CueFamily, FacetClass, LearningCandidate};
use crate::openhuman::memory::store::profile::{FacetState, FacetType, ProfileFacet, UserState};
// ── Thresholds ────────────────────────────────────────────────────────────────
/// Stability threshold to enter / stay as Active.
pub const TAU_PROMOTE: f64 = 1.5;
/// Stability threshold to enter / stay as Provisional.
pub const TAU_PROVISIONAL: f64 = 0.7;
/// Stability threshold to retain as Candidate (below → Dropped).
pub const TAU_EVICT: f64 = 0.4;
// ── Half-lives (seconds) ──────────────────────────────────────────────────────
pub const HALF_LIFE_IDENTITY: f64 = 90.0 * 86400.0;
pub const HALF_LIFE_VETO: f64 = 60.0 * 86400.0;
pub const HALF_LIFE_TOOLING: f64 = 30.0 * 86400.0;
pub const HALF_LIFE_GOAL: f64 = 30.0 * 86400.0;
pub const HALF_LIFE_STYLE: f64 = 14.0 * 86400.0;
pub const HALF_LIFE_CHANNEL: f64 = 7.0 * 86400.0;
/// Half-life in seconds for the given class.
pub fn half_life(class: FacetClass) -> f64 {
match class {
FacetClass::Identity => HALF_LIFE_IDENTITY,
FacetClass::Veto => HALF_LIFE_VETO,
FacetClass::Tooling => HALF_LIFE_TOOLING,
FacetClass::Goal => HALF_LIFE_GOAL,
FacetClass::Style => HALF_LIFE_STYLE,
FacetClass::Channel => HALF_LIFE_CHANNEL,
}
}
// ── Class budgets ─────────────────────────────────────────────────────────────
pub const BUDGET_STYLE: usize = 4;
pub const BUDGET_IDENTITY: usize = 4;
pub const BUDGET_TOOLING: usize = 5;
pub const BUDGET_VETO: usize = 3;
pub const BUDGET_GOAL: usize = 3;
pub const BUDGET_CHANNEL: usize = 1;
/// Cross-class overflow pool for Provisional rows that didn't make a class budget.
pub const BUDGET_OVERFLOW: usize = 5;
/// Per-class top-N budget for Active rows.
pub fn class_budget(class: FacetClass) -> usize {
match class {
FacetClass::Style => BUDGET_STYLE,
FacetClass::Identity => BUDGET_IDENTITY,
FacetClass::Tooling => BUDGET_TOOLING,
FacetClass::Veto => BUDGET_VETO,
FacetClass::Goal => BUDGET_GOAL,
FacetClass::Channel => BUDGET_CHANNEL,
}
}
// ── Stability formula ─────────────────────────────────────────────────────────
/// Compute the stability score for a `(class, key)` aggregate.
///
/// # Arguments
///
/// * `cue_family` — dominant cue family of the evidence set
/// * `evidence_count` — total pieces of evidence contributing
/// * `last_reinforced_at` — Unix seconds of the most recent evidence
/// * `now` — current Unix seconds
/// * `class` — facet class (determines half-life)
/// * `has_explicit_evidence` — whether any evidence has `CueFamily::Explicit`
/// * `user_state` — user override (Pinned → ∞, Forgotten → 0)
pub fn stability(
cue_family: CueFamily,
evidence_count: u32,
last_reinforced_at: f64,
now: f64,
class: FacetClass,
has_explicit_evidence: bool,
user_state: UserState,
) -> f64 {
if matches!(user_state, UserState::Pinned) {
return f64::INFINITY;
}
if matches!(user_state, UserState::Forgotten) {
return 0.0;
}
let dt = (now - last_reinforced_at).max(0.0);
let recency = (-dt / half_life(class)).exp();
let base = cue_family.weight() * recency * (1.0 + evidence_count as f64).ln();
let cue_mult = if has_explicit_evidence { 2.0 } else { 1.0 };
base * cue_mult
}
// ── Rebuild outcome ───────────────────────────────────────────────────────────
/// Summary of a single rebuild cycle.
#[derive(Debug, Clone)]
pub struct RebuildOutcome {
/// Facet rows newly created in this cycle (key not previously in the table).
pub added: usize,
/// Facet rows that were demoted to Dropped or deleted.
pub evicted: usize,
/// Facet rows carried over from the previous cycle without changes.
pub kept: usize,
/// Total rows in the cache after the rebuild.
pub total_size: usize,
}
// ── StabilityDetector ─────────────────────────────────────────────────────────
/// The stability detector.
///
/// Owns a [`FacetCache`] and a reference to the global [`candidate::Buffer`].
/// Call [`StabilityDetector::rebuild`] to run one full cycle.
pub struct StabilityDetector {
pub(crate) cache: FacetCache,
pub(crate) buffer: &'static candidate::Buffer,
}
impl StabilityDetector {
/// Create a new detector backed by the given cache.
///
/// Uses [`candidate::global()`] as the source buffer.
pub fn new(cache: FacetCache) -> Self {
Self {
cache,
buffer: candidate::global(),
}
}
/// Run one full rebuild cycle.
///
/// 1. Drain the global candidate buffer.
/// 2. Load all existing facets from the cache.
/// 3. For each `(class, key)`, aggregate evidence + existing stability.
/// 4. Choose the winning value via `argmax(stability)`.
/// 5. Assign lifecycle state based on thresholds.
/// 6. Apply per-class budgets (demote excess Active → Provisional).
/// 7. Persist changes and delete Dropped rows.
/// 8. Emit `DomainEvent::CacheRebuilt`.
pub fn rebuild(&self, now: f64) -> anyhow::Result<RebuildOutcome> {
tracing::debug!("[learning::stability] rebuild starting at t={now:.0}");
// Step 1 — drain buffer.
let candidates = self.buffer.drain();
tracing::debug!(
"[learning::stability] drained {} candidates from buffer",
candidates.len()
);
// Step 2 — load existing facets.
let existing_facets = self.cache.list_all()?;
let existing_by_key: HashMap<String, ProfileFacet> = existing_facets
.into_iter()
.map(|f| (f.key.clone(), f))
.collect();
// Step 3 — group candidates by (class, key).
// For candidates whose key has no class prefix, we skip them (they're legacy rows).
let mut groups: HashMap<(FacetClass, String), Vec<LearningCandidate>> = HashMap::new();
for cand in candidates {
let full_key = format!("{}/{}", class_prefix(cand.class), cand.key);
groups.entry((cand.class, full_key)).or_default().push(cand);
}
// Also process existing facets that have a known class prefix (so they decay).
for key in existing_by_key.keys() {
if let Some(class) = crate::openhuman::learning::cache::class_from_key(key) {
groups.entry((class, key.to_string())).or_default(); // ensure the group exists even if no new candidates
}
}
// Step 4-6: compute new state for each (class, key).
// We accumulate the final facets per class so we can apply budgets.
let mut by_class: HashMap<FacetClass, Vec<ComputedFacet>> = HashMap::new();
for ((class, full_key), cands) in &groups {
let existing = existing_by_key.get(full_key);
// Respect Forgotten user_state: block re-promotion.
let user_state = existing.map(|f| f.user_state).unwrap_or(UserState::Auto);
// Step 4: for each distinct value, compute a candidate score.
let winning_value = select_winning_value(cands, existing, now, *class);
// Step 5: compute stability of the winning (class, key) aggregate.
let (agg_score, has_explicit) = aggregate_stability(cands, existing, now, *class);
let final_stability = stability(
dominant_cue(cands, existing),
total_evidence_count(cands, existing),
most_recent_reinforcement(cands, existing, now),
now,
*class,
has_explicit,
user_state,
);
tracing::trace!(
"[learning::stability] {full_key}: value={:?} agg={agg_score:.3} final={final_stability:.3}",
winning_value.as_deref().unwrap_or("(none)"),
);
// Step 7: state assignment.
let state = state_from_stability(final_stability, user_state);
let value = match winning_value.or_else(|| existing.map(|f| f.value.clone())) {
Some(v) => v,
None => {
// No candidates and no existing row — shouldn't happen but skip.
continue;
}
};
let existing_count = existing.map(|f| f.evidence_count).unwrap_or(0);
let new_evidence_count = (existing_count + cands.len() as i32).max(1);
let facet_id = existing
.map(|f| f.facet_id.clone())
.unwrap_or_else(|| format!("learn-{}", full_key.replace('/', "-")));
let first_seen = existing.map(|f| f.first_seen_at).unwrap_or(now);
// Collect evidence refs from new candidates.
let new_refs: Vec<crate::openhuman::learning::candidate::EvidenceRef> =
cands.iter().map(|c| c.evidence.clone()).collect();
let mut all_refs = existing
.map(|f| f.evidence_refs.clone())
.unwrap_or_default();
all_refs.extend(new_refs);
// Deduplicate refs by serialised form (cheap for small vecs).
all_refs
.dedup_by(|a, b| serde_json::to_string(a).ok() == serde_json::to_string(b).ok());
// Build cue-families counts from this cycle's candidates.
let mut cue_counts: HashMap<String, u32> = HashMap::new();
for c in cands {
*cue_counts
.entry(format!("{:?}", c.cue_family).to_lowercase())
.or_insert(0) += 1;
}
// Merge with existing cue counts if present.
if let Some(existing_cues) = existing.and_then(|f| f.cue_families.as_ref()) {
for (k, v) in existing_cues {
*cue_counts.entry(k.clone()).or_insert(0) += v;
}
}
let computed = ComputedFacet {
is_new: existing.is_none(),
facet: ProfileFacet {
facet_id,
facet_type: FacetType::Preference,
key: full_key.clone(),
value,
confidence: (agg_score).min(1.0).max(0.0),
evidence_count: new_evidence_count,
source_segment_ids: existing.and_then(|f| f.source_segment_ids.clone()),
first_seen_at: first_seen,
last_seen_at: now,
state,
stability: final_stability,
user_state,
evidence_refs: all_refs,
// Class derived from the key prefix (always set for learning rows).
class: Some(class_prefix(*class).to_string()),
cue_families: if cue_counts.is_empty() {
None
} else {
Some(cue_counts)
},
},
};
by_class.entry(*class).or_default().push(computed);
}
// Step 6 (budget): per-class enforce top-N Active.
let mut overflow_provisional: Vec<ComputedFacet> = Vec::new();
let mut all_final: Vec<ComputedFacet> = Vec::new();
for (class, mut facets) in by_class {
// Sort Active rows by stability desc.
facets.sort_by(|a, b| {
b.facet
.stability
.partial_cmp(&a.facet.stability)
.unwrap_or(std::cmp::Ordering::Equal)
});
let budget = class_budget(class);
let mut active_count = 0usize;
for mut cf in facets {
if cf.facet.state == FacetState::Active {
if active_count < budget {
active_count += 1;
} else {
// Demote excess Active → Provisional and send to overflow pool.
cf.facet.state = FacetState::Provisional;
overflow_provisional.push(cf);
continue;
}
}
all_final.push(cf);
}
}
// Apply overflow budget: keep top BUDGET_OVERFLOW Provisional rows.
overflow_provisional.sort_by(|a, b| {
b.facet
.stability
.partial_cmp(&a.facet.stability)
.unwrap_or(std::cmp::Ordering::Equal)
});
for cf in overflow_provisional.into_iter().take(BUDGET_OVERFLOW) {
all_final.push(cf);
}
// Step 7 — persist and compute outcome.
let mut added = 0usize;
let mut kept = 0usize;
let mut evicted = 0usize;
for cf in &all_final {
if cf.facet.state == FacetState::Dropped {
evicted += 1;
} else if cf.is_new {
added += 1;
} else {
kept += 1;
}
self.cache.upsert(&cf.facet)?;
}
// (Existing keys not in the rebuild output are legacy/non-class rows — skip.)
// Clean up Dropped rows from the table.
let cleaned = self.cache.drop_below_threshold(TAU_EVICT)?;
if cleaned > 0 {
tracing::debug!(
"[learning::stability] cleaned {cleaned} rows below threshold from table"
);
}
let active_rows = self.cache.list_active()?;
let total_size = active_rows.len();
tracing::info!(
"[learning::stability] rebuild added={added} evicted={evicted} kept={kept} total={total_size}"
);
// Step 8 — publish CacheRebuilt event.
event_bus::publish_global(DomainEvent::CacheRebuilt {
added,
evicted,
kept,
total_size,
rebuilt_at: now,
});
Ok(RebuildOutcome {
added,
evicted,
kept,
total_size,
})
}
}
// ── Rebuild internals ─────────────────────────────────────────────────────────
struct ComputedFacet {
is_new: bool,
facet: ProfileFacet,
}
/// Choose the winning value for a `(class, key)` group via `argmax(stability)`.
///
/// Returns the value with the highest combined evidence weight. Falls back to the
/// existing row's value if no candidates are present.
fn select_winning_value(
cands: &[LearningCandidate],
existing: Option<&ProfileFacet>,
now: f64,
class: FacetClass,
) -> Option<String> {
if cands.is_empty() {
return existing.map(|f| f.value.clone());
}
// Score each distinct value.
let mut value_scores: HashMap<&str, f64> = HashMap::new();
for c in cands {
let dt = (now - c.observed_at).max(0.0);
let recency = (-dt / half_life(class)).exp();
let score = c.cue_family.weight() * recency * c.initial_confidence;
*value_scores.entry(c.value.as_str()).or_default() += score;
}
// If existing row matches a candidate value, add its weight too.
if let Some(existing) = existing {
let dt = (now - existing.last_seen_at).max(0.0);
let recency = (-dt / half_life(class)).exp();
let existing_score = recency * existing.confidence;
*value_scores.entry(existing.value.as_str()).or_default() += existing_score;
}
value_scores
.into_iter()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(v, _)| v.to_string())
}
/// Aggregate stability contribution from all candidates (not per-value).
/// Returns (aggregate_score, has_explicit_evidence).
fn aggregate_stability(
cands: &[LearningCandidate],
existing: Option<&ProfileFacet>,
now: f64,
class: FacetClass,
) -> (f64, bool) {
let mut score = 0.0f64;
let mut has_explicit = false;
for c in cands {
let dt = (now - c.observed_at).max(0.0);
let recency = (-dt / half_life(class)).exp();
score += c.cue_family.weight() * recency;
if matches!(c.cue_family, CueFamily::Explicit) {
has_explicit = true;
}
}
if let Some(existing) = existing {
let dt = (now - existing.last_seen_at).max(0.0);
let recency = (-dt / half_life(class)).exp();
score += existing.confidence * recency;
}
(score, has_explicit)
}
/// Determine the dominant cue family (highest weight).
fn dominant_cue(cands: &[LearningCandidate], _existing: Option<&ProfileFacet>) -> CueFamily {
cands
.iter()
.max_by(|a, b| {
a.cue_family
.weight()
.partial_cmp(&b.cue_family.weight())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|c| c.cue_family)
.unwrap_or(CueFamily::Behavioral)
}
/// Total evidence count from candidates + existing row.
fn total_evidence_count(cands: &[LearningCandidate], existing: Option<&ProfileFacet>) -> u32 {
let from_existing = existing.map(|f| f.evidence_count as u32).unwrap_or(0);
from_existing + cands.len() as u32
}
/// The most recent observation timestamp across candidates and the existing row.
fn most_recent_reinforcement(
cands: &[LearningCandidate],
existing: Option<&ProfileFacet>,
now: f64,
) -> f64 {
let newest_cand = cands
.iter()
.map(|c| c.observed_at)
.fold(f64::NEG_INFINITY, f64::max);
let existing_ts = existing
.map(|f| f.last_seen_at)
.unwrap_or(f64::NEG_INFINITY);
newest_cand
.max(existing_ts)
.max(now - half_life(FacetClass::Style))
}
/// Map a stability score + user_state to a lifecycle state.
fn state_from_stability(score: f64, user_state: UserState) -> FacetState {
// Pinned → always Active; Forgotten → always Dropped.
if matches!(user_state, UserState::Pinned) {
return FacetState::Active;
}
if matches!(user_state, UserState::Forgotten) {
return FacetState::Dropped;
}
if score.is_infinite() {
FacetState::Active
} else if score >= TAU_PROMOTE {
FacetState::Active
} else if score >= TAU_PROVISIONAL {
FacetState::Provisional
} else if score >= TAU_EVICT {
FacetState::Candidate
} else {
FacetState::Dropped
}
}
/// Canonical key prefix string for a class (used when grouping candidates).
fn class_prefix(class: FacetClass) -> &'static str {
crate::openhuman::learning::cache::class_prefix(class)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::learning::cache::FacetCache;
use crate::openhuman::learning::candidate::{
Buffer, EvidenceRef, FacetClass, LearningCandidate,
};
use crate::openhuman::memory::store::profile::PROFILE_INIT_SQL;
use parking_lot::Mutex;
use rusqlite::Connection;
use std::sync::Arc;
fn make_detector() -> StabilityDetector {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(PROFILE_INIT_SQL).unwrap();
let cache = FacetCache::new(Arc::new(Mutex::new(conn)));
// Use a private buffer so tests don't interfere with the global singleton.
let buffer: &'static Buffer = Box::leak(Box::new(Buffer::new(256)));
StabilityDetector { cache, buffer }
}
fn make_candidate(
class: FacetClass,
key: &str,
value: &str,
cue: CueFamily,
observed_at: f64,
) -> LearningCandidate {
LearningCandidate {
class,
key: key.into(),
value: value.into(),
cue_family: cue,
evidence: EvidenceRef::Episodic { episodic_id: 1 },
initial_confidence: 0.8,
observed_at,
}
}
// ── stability formula ────────────────────────────────────────────────────
#[test]
fn stability_pinned_returns_infinity() {
let s = stability(
CueFamily::Behavioral,
5,
0.0,
1000.0,
FacetClass::Style,
false,
UserState::Pinned,
);
assert!(s.is_infinite() && s > 0.0);
}
#[test]
fn stability_forgotten_returns_zero() {
let s = stability(
CueFamily::Explicit,
100,
0.0,
1000.0,
FacetClass::Style,
true,
UserState::Forgotten,
);
assert_eq!(s, 0.0);
}
#[test]
fn stability_explicit_doubles_score() {
let base = stability(
CueFamily::Explicit,
3,
1_000_000.0,
1_000_001.0,
FacetClass::Style,
false, // no_explicit
UserState::Auto,
);
let with_explicit = stability(
CueFamily::Explicit,
3,
1_000_000.0,
1_000_001.0,
FacetClass::Style,
true, // has_explicit
UserState::Auto,
);
assert!(
(with_explicit - 2.0 * base).abs() < 1e-9,
"explicit multiplier must be exactly 2x: base={base:.6} explicit={with_explicit:.6}"
);
}
#[test]
fn stability_decays_over_time() {
let now = 1_000_000.0_f64;
let recent = stability(
CueFamily::Behavioral,
5,
now - 100.0, // observed 100 s ago
now,
FacetClass::Style,
false,
UserState::Auto,
);
let old = stability(
CueFamily::Behavioral,
5,
now - HALF_LIFE_STYLE, // observed one half-life ago
now,
FacetClass::Style,
false,
UserState::Auto,
);
assert!(
recent > old,
"recent evidence should produce higher stability: recent={recent:.4} old={old:.4}"
);
// At exactly one half-life, recency = exp(-1) ≈ 0.368.
assert!(
old / recent < 0.4,
"decay over one half-life should be substantial: ratio={}",
old / recent
);
}
// ── rebuild ──────────────────────────────────────────────────────────────
#[test]
fn rebuild_empty_buffer_no_candidates_is_noop() {
let detector = make_detector();
let now = 1_000_000.0;
// No candidates, no existing rows → rebuild is a no-op.
let outcome = detector.rebuild(now).unwrap();
assert_eq!(outcome.added, 0);
assert_eq!(outcome.evicted, 0);
assert_eq!(outcome.kept, 0);
assert_eq!(outcome.total_size, 0);
}
#[test]
fn rebuild_strong_candidate_becomes_active() {
let detector = make_detector();
let now = 1_000_000.0;
// Push enough explicit evidence to clear τ_promote.
for i in 0..5 {
detector.buffer.push(make_candidate(
FacetClass::Style,
"verbosity",
"terse",
CueFamily::Explicit,
now - i as f64 * 10.0,
));
}
let outcome = detector.rebuild(now).unwrap();
assert_eq!(outcome.added, 1);
let actives = detector.cache.list_active().unwrap();
assert_eq!(actives.len(), 1);
assert_eq!(actives[0].key, "style/verbosity");
assert_eq!(actives[0].value, "terse");
assert_eq!(actives[0].state, FacetState::Active);
}
#[test]
fn rebuild_conflict_resolution_picks_stronger_value() {
let detector = make_detector();
let now = 1_000_000.0;
// 3 explicit candidates for "terse", 1 behavioral for "verbose".
for _ in 0..3 {
detector.buffer.push(make_candidate(
FacetClass::Style,
"verbosity",
"terse",
CueFamily::Explicit,
now - 10.0,
));
}
detector.buffer.push(make_candidate(
FacetClass::Style,
"verbosity",
"verbose",
CueFamily::Behavioral,
now - 5.0,
));
detector.rebuild(now).unwrap();
let actives = detector.cache.list_active().unwrap();
assert!(!actives.is_empty(), "should have at least one active row");
let verbosity = actives.iter().find(|f| f.key == "style/verbosity").unwrap();
assert_eq!(
verbosity.value, "terse",
"terse had stronger evidence and should win"
);
}
#[test]
fn rebuild_class_budget_respected() {
let detector = make_detector();
let now = 1_000_000.0;
// Push 6 different style keys — budget is BUDGET_STYLE = 4.
for i in 0..6 {
let key = format!("style_key_{i}");
// Push several candidates per key so they clear τ_promote.
for j in 0..5 {
detector.buffer.push(LearningCandidate {
class: FacetClass::Style,
key: key.clone(),
value: "v".into(),
cue_family: CueFamily::Explicit,
evidence: EvidenceRef::Episodic {
episodic_id: i * 10 + j,
},
initial_confidence: 0.9,
observed_at: now - j as f64,
});
}
}
detector.rebuild(now).unwrap();
let by_class = detector.cache.list_by_class(FacetClass::Style).unwrap();
assert!(
by_class.len() <= BUDGET_STYLE,
"style class should have at most {BUDGET_STYLE} active rows, got {}",
by_class.len()
);
}
#[test]
fn rebuild_pinned_facet_stays_active_regardless_of_stability() {
let detector = make_detector();
let now = 1_000_000.0;
// Manually insert a Pinned row.
use crate::openhuman::memory::store::profile::{FacetState, FacetType, UserState};
let pinned = ProfileFacet {
facet_id: "f-pinned".into(),
facet_type: FacetType::Preference,
key: "style/format".into(),
value: "markdown".into(),
confidence: 0.9,
evidence_count: 1,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1000.0, // very old — would normally decay
state: FacetState::Active,
stability: 0.0,
user_state: UserState::Pinned,
evidence_refs: vec![],
class: Some("style".into()),
cue_families: None,
};
detector.cache.upsert(&pinned).unwrap();
// No new candidates for this key → only decay applies.
detector.rebuild(now).unwrap();
let f = detector
.cache
.get("style/format")
.unwrap()
.expect("pinned row must survive");
assert_eq!(f.state, FacetState::Active);
}
// ── half_life ────────────────────────────────────────────────────────────
#[test]
fn half_life_ordering_matches_spec() {
// Identity decays slowest; Channel decays fastest.
assert!(half_life(FacetClass::Identity) > half_life(FacetClass::Veto));
assert!(half_life(FacetClass::Veto) > half_life(FacetClass::Tooling));
assert!(half_life(FacetClass::Tooling) >= half_life(FacetClass::Goal));
assert!(half_life(FacetClass::Goal) > half_life(FacetClass::Style));
assert!(half_life(FacetClass::Style) > half_life(FacetClass::Channel));
}
// ── class_budget ────────────────────────────────────────────────────────
#[test]
fn class_budget_values_match_spec() {
assert_eq!(class_budget(FacetClass::Style), 4);
assert_eq!(class_budget(FacetClass::Identity), 4);
assert_eq!(class_budget(FacetClass::Tooling), 5);
assert_eq!(class_budget(FacetClass::Veto), 3);
assert_eq!(class_budget(FacetClass::Goal), 3);
assert_eq!(class_budget(FacetClass::Channel), 1);
}
}
@@ -131,6 +131,23 @@ impl UnifiedMemory {
// User profile accumulation table.
conn.execute_batch(super::profile::PROFILE_INIT_SQL)?;
// Phase 3 (#566): idempotently add new columns to existing databases.
// New installs already have these columns via PROFILE_INIT_SQL above.
// Existing DBs need the migration to add state/stability/user_state/evidence_refs_json.
// We run the ALTER TABLE statements directly on `conn` before wrapping it in Arc<Mutex>.
// SQL arrays are defined as constants in profile.rs to avoid duplication.
{
use super::profile::{PHASE3_COLUMNS_SQL, PHASE3_INDEXES_SQL};
for sql in PHASE3_COLUMNS_SQL.iter().chain(PHASE3_INDEXES_SQL.iter()) {
match conn.execute(sql, []) {
Ok(_) => tracing::debug!("[profile:init] applied: {sql}"),
Err(e) => {
tracing::trace!("[profile:init] skipped (probably already exists): {e}")
}
}
}
}
// Idempotent legacy-namespace migration.
//
// Older writes via MemoryStoreTool packed the intended namespace into
+468 -21
View File
@@ -6,12 +6,21 @@
//! and evidence counts. On conflict (same facet_type + key), evidence_count
//! is incremented; the value is only overwritten if the new confidence is
//! higher.
//!
//! ## Phase 3 schema additions (#566)
//!
//! Added `state`, `stability`, `user_state`, and `evidence_refs_json` columns.
//! Existing databases are migrated idempotently via `ALTER TABLE … ADD COLUMN`
//! wrapped in `migrate_profile_schema()`.
use parking_lot::Mutex;
use rusqlite::{params, Connection};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use crate::openhuman::learning::candidate::EvidenceRef;
/// SQL to create the user_profile table. Called during UnifiedMemory init.
pub const PROFILE_INIT_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS user_profile (
@@ -24,13 +33,155 @@ CREATE TABLE IF NOT EXISTS user_profile (
source_segment_ids TEXT,
first_seen_at REAL NOT NULL,
last_seen_at REAL NOT NULL,
state TEXT NOT NULL DEFAULT 'active',
stability REAL NOT NULL DEFAULT 0.0,
user_state TEXT NOT NULL DEFAULT 'auto',
evidence_refs_json TEXT,
class TEXT,
cue_families_json TEXT,
UNIQUE(facet_type, key)
);
CREATE INDEX IF NOT EXISTS idx_profile_type
ON user_profile(facet_type);
CREATE INDEX IF NOT EXISTS idx_profile_state
ON user_profile(state);
CREATE INDEX IF NOT EXISTS idx_profile_class
ON user_profile(class);
"#;
/// Phase 3 ALTER TABLE statements for adding new columns to existing databases.
///
/// Used by both `migrate_profile_schema` (post-Arc-wrap path) and
/// `init.rs` (pre-Arc-wrap path) to avoid duplicating the SQL.
pub const PHASE3_COLUMNS_SQL: &[&str] = &[
"ALTER TABLE user_profile ADD COLUMN state TEXT NOT NULL DEFAULT 'active'",
"ALTER TABLE user_profile ADD COLUMN stability REAL NOT NULL DEFAULT 0.0",
"ALTER TABLE user_profile ADD COLUMN user_state TEXT NOT NULL DEFAULT 'auto'",
"ALTER TABLE user_profile ADD COLUMN evidence_refs_json TEXT",
"ALTER TABLE user_profile ADD COLUMN class TEXT",
"ALTER TABLE user_profile ADD COLUMN cue_families_json TEXT",
];
/// Phase 3 index creation statements. Idempotent (IF NOT EXISTS).
///
/// Shared between `migrate_profile_schema` and `init.rs`.
pub const PHASE3_INDEXES_SQL: &[&str] = &[
"CREATE INDEX IF NOT EXISTS idx_profile_state ON user_profile(state)",
"CREATE INDEX IF NOT EXISTS idx_profile_class ON user_profile(class)",
];
/// Idempotent schema migration for existing databases.
///
/// New installs get the full schema from `PROFILE_INIT_SQL`. Existing databases
/// may be missing the Phase 3 columns. This function adds each new column if it
/// doesn't exist, ignoring the "duplicate column name" error that SQLite returns
/// when the column is already present.
pub fn migrate_profile_schema(conn: &Arc<Mutex<Connection>>) {
let conn = conn.lock();
for sql in PHASE3_COLUMNS_SQL {
match conn.execute(sql, []) {
Ok(_) => {
tracing::debug!("[profile] schema migration applied: {sql}");
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.extended_code == rusqlite::ffi::SQLITE_ERROR =>
{
// "duplicate column name" is not a named SQLite error code; it comes
// back as a generic SQLITE_ERROR with the text "duplicate column name".
// We tolerate any SQLITE_ERROR here because that's the only class of
// error this ALTER TABLE can produce when the column already exists.
tracing::trace!("[profile] column already present (ok): {sql}");
}
Err(e) => {
tracing::warn!("[profile] schema migration failed (non-fatal): {sql}: {e}");
}
}
}
// Ensure the new indexes exist (idempotent — IF NOT EXISTS).
for sql in PHASE3_INDEXES_SQL {
if let Err(e) = conn.execute(sql, []) {
tracing::warn!("[profile] index creation failed (non-fatal): {sql}: {e}");
}
}
}
// ── FacetState ───────────────────────────────────────────────────────────────
/// Lifecycle state of a profile facet in the stability detector.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum FacetState {
/// Facet has cleared τ_promote and is included in the ambient cache.
#[default]
Active,
/// Facet is between τ_provisional and τ_promote — included at lower weight.
Provisional,
/// Facet is between τ_evict and τ_provisional — held as a candidate.
Candidate,
/// Facet fell below τ_evict — will be removed on next rebuild.
Dropped,
}
impl FacetState {
pub fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Provisional => "provisional",
Self::Candidate => "candidate",
Self::Dropped => "dropped",
}
}
pub fn parse_or_default(s: &str) -> Self {
match s {
"provisional" => Self::Provisional,
"candidate" => Self::Candidate,
"dropped" => Self::Dropped,
_ => Self::Active,
}
}
}
// ── UserState ────────────────────────────────────────────────────────────────
/// User-controlled override for a profile facet.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum UserState {
/// No user override — stability detector manages the lifecycle.
#[default]
Auto,
/// User has explicitly pinned this facet; it stays Active regardless of score.
Pinned,
/// User has explicitly forgotten this facet; it stays Dropped and cannot be
/// re-promoted by new evidence.
Forgotten,
}
impl UserState {
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Pinned => "pinned",
Self::Forgotten => "forgotten",
}
}
pub fn parse_or_default(s: &str) -> Self {
match s {
"pinned" => Self::Pinned,
"forgotten" => Self::Forgotten,
_ => Self::Auto,
}
}
}
// ── FacetType ────────────────────────────────────────────────────────────────
/// Profile facet types.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -67,7 +218,9 @@ impl FacetType {
}
}
/// A single profile facet.
// ── ProfileFacet ─────────────────────────────────────────────────────────────
/// A single profile facet — extended with Phase 3 state + stability fields.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileFacet {
pub facet_id: String,
@@ -79,13 +232,39 @@ pub struct ProfileFacet {
pub source_segment_ids: Option<String>,
pub first_seen_at: f64,
pub last_seen_at: f64,
// ── Phase 3 additions ──
/// Lifecycle state assigned by the stability detector.
pub state: FacetState,
/// Computed stability score from the last rebuild cycle.
pub stability: f64,
/// User-controlled override.
pub user_state: UserState,
/// Provenance references deserialized from `evidence_refs_json`.
pub evidence_refs: Vec<EvidenceRef>,
/// Facet class (style / identity / tooling / veto / goal / channel).
///
/// Derived from the key prefix (e.g. `"style/verbosity"` → `"style"`) for
/// learning-path rows. `None` for legacy provider rows whose key prefix
/// doesn't match a known class.
pub class: Option<String>,
/// Per-cue-family evidence counts serialized as JSON.
///
/// Shape: `{"explicit": N, "structural": N, "behavioral": N, "recurrence": N}`.
/// `None` until the stability detector writes the first rebuild.
pub cue_families: Option<std::collections::HashMap<String, u32>>,
}
/// Upsert a profile facet. On conflict (same facet_type + key):
// ── Write helpers ─────────────────────────────────────────────────────────────
/// Upsert a profile facet (legacy / provider path). On conflict (same facet_type + key):
/// - Increments evidence_count
/// - Updates last_seen_at
/// - Appends segment_id to source_segment_ids
/// - Only overwrites value if new confidence > existing confidence
///
/// The new Phase 3 columns (`state`, `stability`, `user_state`,
/// `evidence_refs_json`) default to `active`, `0.0`, `auto`, and `NULL`
/// respectively, so existing callers need no changes.
#[allow(clippy::too_many_arguments)]
pub fn profile_upsert(
conn: &Arc<Mutex<Connection>>,
@@ -110,18 +289,7 @@ pub fn profile_upsert(
.ok();
if let Some((existing_id, existing_confidence, existing_count, existing_segments)) = existing {
let new_segments = match (existing_segments, segment_id) {
(Some(existing), Some(sid)) => {
if existing.contains(sid) {
existing
} else {
format!("{existing},{sid}")
}
}
(Some(existing), None) => existing,
(None, Some(sid)) => sid.to_string(),
(None, None) => String::new(),
};
let new_segments = merge_segments(existing_segments, segment_id);
if confidence >= existing_confidence {
// Higher or equal confidence: overwrite value + update metadata.
@@ -155,13 +323,17 @@ pub fn profile_upsert(
existing_count + 1
);
} else {
// Insert new facet.
// Insert new facet. Derive class from the key prefix for learning rows.
let segments = segment_id.unwrap_or("").to_string();
let class = infer_class_from_key(key, facet_type);
conn.execute(
"INSERT INTO user_profile
(facet_id, facet_type, key, value, confidence, evidence_count,
source_segment_ids, first_seen_at, last_seen_at)
VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, ?7)",
source_segment_ids, first_seen_at, last_seen_at,
state, stability, user_state, evidence_refs_json,
class, cue_families_json)
VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6, ?7, ?7, 'active', 0.0, 'auto', NULL,
?8, NULL)",
params![
facet_id,
facet_type.as_str(),
@@ -170,6 +342,7 @@ pub fn profile_upsert(
confidence,
segments,
now,
class,
],
)?;
tracing::debug!(
@@ -183,12 +356,133 @@ pub fn profile_upsert(
Ok(())
}
/// Full upsert used by the stability detector rebuild path.
///
/// Writes all Phase 3 columns explicitly. On conflict (same facet_type + key)
/// the row is replaced in full — the rebuild owns these rows.
pub fn profile_upsert_full(
conn: &Arc<Mutex<Connection>>,
facet: &ProfileFacet,
) -> anyhow::Result<()> {
let evidence_refs_json = if facet.evidence_refs.is_empty() {
None
} else {
Some(serde_json::to_string(&facet.evidence_refs)?)
};
let cue_families_json = facet
.cue_families
.as_ref()
.filter(|m| !m.is_empty())
.map(|m| serde_json::to_string(m))
.transpose()?;
// Derive class from the facet's own class field or fall back to key prefix.
let class = facet
.class
.clone()
.or_else(|| infer_class_from_key(&facet.key, &facet.facet_type));
let conn = conn.lock();
// Use INSERT OR REPLACE to atomically update all columns including
// state/stability without reading the row first. Note: on a UNIQUE(facet_type,
// key) conflict, SQLite performs DELETE + INSERT rather than an in-place
// update, which means facet_id will change for conflicting rows. This is
// intentional: the stability detector owns these rows during rebuild and
// provides consistent facet_id values; external references by facet_id are
// not expected.
conn.execute(
"INSERT OR REPLACE INTO user_profile
(facet_id, facet_type, key, value, confidence, evidence_count,
source_segment_ids, first_seen_at, last_seen_at,
state, stability, user_state, evidence_refs_json,
class, cue_families_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13,
?14, ?15)",
params![
facet.facet_id,
facet.facet_type.as_str(),
facet.key,
facet.value,
facet.confidence,
facet.evidence_count,
facet.source_segment_ids,
facet.first_seen_at,
facet.last_seen_at,
facet.state.as_str(),
facet.stability,
facet.user_state.as_str(),
evidence_refs_json,
class,
cue_families_json,
],
)?;
tracing::debug!(
"[profile] full-upsert facet {}:{} = {} (state={}, stability={:.3}, class={:?})",
facet.facet_type.as_str(),
facet.key,
facet.value,
facet.state.as_str(),
facet.stability,
class,
);
Ok(())
}
/// Update the `user_state` column for a facet by key.
///
/// Returns `Ok(true)` if a row was updated, `Ok(false)` if not found.
pub fn profile_set_user_state(
conn: &Arc<Mutex<Connection>>,
key: &str,
user_state: UserState,
) -> anyhow::Result<bool> {
let conn = conn.lock();
let rows = conn.execute(
"UPDATE user_profile SET user_state = ?1 WHERE key = ?2",
params![user_state.as_str(), key],
)?;
Ok(rows > 0)
}
/// Delete a facet by key. Returns `true` if a row was deleted.
pub fn profile_delete_by_key(conn: &Arc<Mutex<Connection>>, key: &str) -> anyhow::Result<bool> {
let conn = conn.lock();
let rows = conn.execute("DELETE FROM user_profile WHERE key = ?1", params![key])?;
Ok(rows > 0)
}
/// Delete all facets whose stability is below the given threshold.
///
/// Facets with `user_state = 'pinned'` are never deleted regardless of score.
/// Returns the number of rows deleted.
pub fn profile_delete_below_threshold(
conn: &Arc<Mutex<Connection>>,
threshold: f64,
) -> anyhow::Result<usize> {
let conn = conn.lock();
let rows = conn.execute(
"DELETE FROM user_profile
WHERE stability < ?1
AND user_state != 'pinned'
AND state = 'dropped'",
params![threshold],
)?;
Ok(rows)
}
// ── Read helpers ──────────────────────────────────────────────────────────────
/// Load all profile facets.
pub fn profile_load_all(conn: &Arc<Mutex<Connection>>) -> anyhow::Result<Vec<ProfileFacet>> {
let conn = conn.lock();
let mut stmt = conn.prepare(
"SELECT facet_id, facet_type, key, value, confidence, evidence_count,
source_segment_ids, first_seen_at, last_seen_at
source_segment_ids, first_seen_at, last_seen_at,
state, stability, user_state, evidence_refs_json,
class, cue_families_json
FROM user_profile
ORDER BY facet_type, evidence_count DESC",
)?;
@@ -198,7 +492,42 @@ pub fn profile_load_all(conn: &Arc<Mutex<Connection>>) -> anyhow::Result<Vec<Pro
Ok(rows)
}
/// Load profile facets by type.
/// Load all facets with `state = 'active'` ordered by stability descending.
pub fn profile_select_active(conn: &Arc<Mutex<Connection>>) -> anyhow::Result<Vec<ProfileFacet>> {
let conn = conn.lock();
let mut stmt = conn.prepare(
"SELECT facet_id, facet_type, key, value, confidence, evidence_count,
source_segment_ids, first_seen_at, last_seen_at,
state, stability, user_state, evidence_refs_json,
class, cue_families_json
FROM user_profile
WHERE state = 'active'
ORDER BY stability DESC",
)?;
let rows = stmt
.query_map([], row_to_facet)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Load all facets regardless of state (used by the rebuild cycle for a full view).
pub fn profile_select_all(conn: &Arc<Mutex<Connection>>) -> anyhow::Result<Vec<ProfileFacet>> {
let conn = conn.lock();
let mut stmt = conn.prepare(
"SELECT facet_id, facet_type, key, value, confidence, evidence_count,
source_segment_ids, first_seen_at, last_seen_at,
state, stability, user_state, evidence_refs_json,
class, cue_families_json
FROM user_profile
ORDER BY stability DESC",
)?;
let rows = stmt
.query_map([], row_to_facet)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Load profile facets by type (legacy path).
pub fn profile_facets_by_type(
conn: &Arc<Mutex<Connection>>,
facet_type: &FacetType,
@@ -206,7 +535,9 @@ pub fn profile_facets_by_type(
let conn = conn.lock();
let mut stmt = conn.prepare(
"SELECT facet_id, facet_type, key, value, confidence, evidence_count,
source_segment_ids, first_seen_at, last_seen_at
source_segment_ids, first_seen_at, last_seen_at,
state, stability, user_state, evidence_refs_json,
class, cue_families_json
FROM user_profile
WHERE facet_type = ?1
ORDER BY evidence_count DESC",
@@ -217,6 +548,51 @@ pub fn profile_facets_by_type(
Ok(rows)
}
/// Load a single facet by key. Returns `None` if not found.
pub fn profile_get_by_key(
conn: &Arc<Mutex<Connection>>,
key: &str,
) -> anyhow::Result<Option<ProfileFacet>> {
let conn = conn.lock();
conn.query_row(
"SELECT facet_id, facet_type, key, value, confidence, evidence_count,
source_segment_ids, first_seen_at, last_seen_at,
state, stability, user_state, evidence_refs_json,
class, cue_families_json
FROM user_profile WHERE key = ?1",
params![key],
row_to_facet,
)
.optional()
.map_err(Into::into)
}
/// Count facets grouped by class prefix (the portion of `key` before the first `/`).
///
/// For example, `style/verbosity` → class `"style"`.
/// Facets whose key contains no `/` are grouped under `"_other"`.
pub fn profile_count_by_class(
conn: &Arc<Mutex<Connection>>,
) -> anyhow::Result<HashMap<String, usize>> {
let conn = conn.lock();
let mut stmt = conn.prepare("SELECT key FROM user_profile WHERE state = 'active'")?;
let keys: Vec<String> = stmt
.query_map([], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?;
let mut counts: HashMap<String, usize> = HashMap::new();
for key in keys {
let class = key
.split_once('/')
.map(|(prefix, _)| prefix.to_string())
.unwrap_or_else(|| "_other".to_string());
*counts.entry(class).or_insert(0) += 1;
}
Ok(counts)
}
// ── Rendering ─────────────────────────────────────────────────────────────────
/// Render profile facets as a markdown section for context assembly.
pub fn render_profile_context(facets: &[ProfileFacet]) -> String {
if facets.is_empty() {
@@ -247,6 +623,55 @@ pub fn render_profile_context(facets: &[ProfileFacet]) -> String {
parts.join("\n\n")
}
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Infer the class label for a facet row from its key prefix and facet_type.
///
/// Learning-path rows use a key like `"style/verbosity"` where the prefix
/// directly encodes the class. Legacy provider rows use `"skill:..."` keys
/// and are mapped via `facet_type`.
fn infer_class_from_key(key: &str, facet_type: &FacetType) -> Option<String> {
// Try key prefix first (learning path: "style/verbosity" → "style").
if let Some((prefix, _)) = key.split_once('/') {
let known = matches!(
prefix,
"style" | "identity" | "tooling" | "veto" | "goal" | "channel"
);
if known {
return Some(prefix.to_string());
}
}
// Legacy provider rows: skill:* keys → "tooling".
if key.starts_with("skill:") {
return Some("tooling".to_string());
}
// Fall back on facet_type.
Some(
match facet_type {
FacetType::Role | FacetType::Personality => "identity",
FacetType::Skill => "tooling",
FacetType::Preference => "style",
FacetType::Context => "identity",
}
.to_string(),
)
}
fn merge_segments(existing: Option<String>, new_sid: Option<&str>) -> String {
match (existing, new_sid) {
(Some(existing), Some(sid)) => {
if existing.contains(sid) {
existing
} else {
format!("{existing},{sid}")
}
}
(Some(existing), None) => existing,
(None, Some(sid)) => sid.to_string(),
(None, None) => String::new(),
}
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
@@ -257,6 +682,22 @@ fn capitalize(s: &str) -> String {
fn row_to_facet(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProfileFacet> {
let facet_type_str: String = row.get(1)?;
let state_str: String = row.get(9)?;
let stability: f64 = row.get(10)?;
let user_state_str: String = row.get(11)?;
let evidence_refs_json: Option<String> = row.get(12)?;
let class: Option<String> = row.get(13)?;
let cue_families_json: Option<String> = row.get(14)?;
let evidence_refs = evidence_refs_json
.as_deref()
.and_then(|json| serde_json::from_str(json).ok())
.unwrap_or_default();
let cue_families = cue_families_json
.as_deref()
.and_then(|json| serde_json::from_str(json).ok());
Ok(ProfileFacet {
facet_id: row.get(0)?,
facet_type: FacetType::parse_or_default(&facet_type_str),
@@ -267,6 +708,12 @@ fn row_to_facet(row: &rusqlite::Row<'_>) -> rusqlite::Result<ProfileFacet> {
source_segment_ids: row.get(6)?,
first_seen_at: row.get(7)?,
last_seen_at: row.get(8)?,
state: FacetState::parse_or_default(&state_str),
stability,
user_state: UserState::parse_or_default(&user_state_str),
evidence_refs,
class,
cue_families,
})
}
@@ -2,6 +2,257 @@
use super::*;
// ── Migration test ────────────────────────────────────────────────────────────
/// Verify that `migrate_profile_schema` adds Phase 3 columns to a database
/// that was created with the pre-Phase-3 schema (missing state/stability/…).
#[test]
fn migrate_adds_new_columns_to_existing_db() {
// Create the pre-Phase-3 schema manually (only original columns).
let pre_phase3_sql = r#"
CREATE TABLE IF NOT EXISTS user_profile (
facet_id TEXT PRIMARY KEY,
facet_type TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0.5,
evidence_count INTEGER NOT NULL DEFAULT 1,
source_segment_ids TEXT,
first_seen_at REAL NOT NULL,
last_seen_at REAL NOT NULL,
UNIQUE(facet_type, key)
);
"#;
let raw_conn = Connection::open_in_memory().unwrap();
raw_conn.execute_batch(pre_phase3_sql).unwrap();
let conn = Arc::new(Mutex::new(raw_conn));
// Insert a row using the old schema.
{
let c = conn.lock();
c.execute(
"INSERT INTO user_profile
(facet_id, facet_type, key, value, confidence, evidence_count,
first_seen_at, last_seen_at)
VALUES ('f-old', 'preference', 'theme', 'dark', 0.8, 1, 1000.0, 1000.0)",
[],
)
.unwrap();
}
// Run the migration — should succeed without panicking.
migrate_profile_schema(&conn);
// The new columns must be present and readable.
let facets = profile_load_all(&conn).unwrap();
assert_eq!(facets.len(), 1);
let f = &facets[0];
assert_eq!(f.key, "theme");
// Defaults applied by ALTER TABLE … DEFAULT.
assert_eq!(f.state, FacetState::Active);
assert!((f.stability - 0.0).abs() < f64::EPSILON);
assert_eq!(f.user_state, UserState::Auto);
assert!(f.evidence_refs.is_empty());
}
/// Running migrate twice is idempotent (no panic on duplicate column).
#[test]
fn migrate_is_idempotent() {
let conn = setup_db();
// First call — columns already exist in PROFILE_INIT_SQL.
migrate_profile_schema(&conn);
// Second call — must not panic.
migrate_profile_schema(&conn);
}
// ── New column round-trip ─────────────────────────────────────────────────────
#[test]
fn profile_upsert_full_persists_phase3_fields() {
use crate::openhuman::learning::candidate::EvidenceRef;
let conn = setup_db();
let facet = ProfileFacet {
facet_id: "f-full".into(),
facet_type: FacetType::Preference,
key: "style/verbosity".into(),
value: "terse".into(),
confidence: 0.9,
evidence_count: 3,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1200.0,
state: FacetState::Active,
stability: 1.8,
user_state: UserState::Auto,
evidence_refs: vec![EvidenceRef::Episodic { episodic_id: 42 }],
class: Some("style".into()),
cue_families: None,
};
profile_upsert_full(&conn, &facet).unwrap();
let loaded = profile_load_all(&conn).unwrap();
assert_eq!(loaded.len(), 1);
let f = &loaded[0];
assert_eq!(f.key, "style/verbosity");
assert_eq!(f.state, FacetState::Active);
assert!((f.stability - 1.8).abs() < 1e-9);
assert_eq!(f.user_state, UserState::Auto);
assert_eq!(f.evidence_refs.len(), 1);
assert_eq!(
f.evidence_refs[0],
EvidenceRef::Episodic { episodic_id: 42 }
);
}
#[test]
fn profile_select_active_filters_by_state() {
let conn = setup_db();
let active = ProfileFacet {
facet_id: "f-active".into(),
facet_type: FacetType::Preference,
key: "style/tone".into(),
value: "formal".into(),
confidence: 0.85,
evidence_count: 2,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1100.0,
state: FacetState::Active,
stability: 1.6,
user_state: UserState::Auto,
evidence_refs: vec![],
class: Some("style".into()),
cue_families: None,
};
let provisional = ProfileFacet {
facet_id: "f-prov".into(),
facet_type: FacetType::Preference,
key: "style/length".into(),
value: "short".into(),
confidence: 0.6,
evidence_count: 1,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1000.0,
state: FacetState::Provisional,
stability: 0.8,
user_state: UserState::Auto,
evidence_refs: vec![],
class: Some("style".into()),
cue_families: None,
};
profile_upsert_full(&conn, &active).unwrap();
profile_upsert_full(&conn, &provisional).unwrap();
let actives = profile_select_active(&conn).unwrap();
assert_eq!(actives.len(), 1);
assert_eq!(actives[0].key, "style/tone");
}
#[test]
fn profile_count_by_class_groups_keys() {
let conn = setup_db();
for (id, key) in [
("f1", "style/verbosity"),
("f2", "style/tone"),
("f3", "identity/name"),
("f4", "no_slash"),
] {
let f = ProfileFacet {
facet_id: id.into(),
facet_type: FacetType::Preference,
key: key.into(),
value: "v".into(),
confidence: 0.8,
evidence_count: 1,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1000.0,
state: FacetState::Active,
stability: 1.6,
user_state: UserState::Auto,
evidence_refs: vec![],
class: None,
cue_families: None,
};
profile_upsert_full(&conn, &f).unwrap();
}
let counts = profile_count_by_class(&conn).unwrap();
assert_eq!(counts.get("style"), Some(&2));
assert_eq!(counts.get("identity"), Some(&1));
assert_eq!(counts.get("_other"), Some(&1));
}
#[test]
fn profile_set_user_state_persists() {
let conn = setup_db();
profile_upsert(
&conn,
"f-us",
&FacetType::Preference,
"tool/editor",
"neovim",
0.8,
None,
1000.0,
)
.unwrap();
let updated = profile_set_user_state(&conn, "tool/editor", UserState::Pinned).unwrap();
assert!(updated);
let f = profile_get_by_key(&conn, "tool/editor").unwrap().unwrap();
assert_eq!(f.user_state, UserState::Pinned);
}
#[test]
fn profile_delete_below_threshold_removes_dropped_only() {
let conn = setup_db();
let dropped_low = ProfileFacet {
facet_id: "f-drop".into(),
facet_type: FacetType::Preference,
key: "style/dropped".into(),
value: "x".into(),
confidence: 0.3,
evidence_count: 1,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1000.0,
state: FacetState::Dropped,
stability: 0.1,
user_state: UserState::Auto,
evidence_refs: vec![],
class: Some("style".into()),
cue_families: None,
};
let active_low = ProfileFacet {
facet_id: "f-act".into(),
facet_type: FacetType::Preference,
key: "style/active".into(),
value: "y".into(),
confidence: 0.9,
evidence_count: 5,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1000.0,
state: FacetState::Active,
stability: 0.1,
user_state: UserState::Auto,
evidence_refs: vec![],
class: Some("style".into()),
cue_families: None,
};
profile_upsert_full(&conn, &dropped_low).unwrap();
profile_upsert_full(&conn, &active_low).unwrap();
let deleted = profile_delete_below_threshold(&conn, 0.3).unwrap();
assert_eq!(deleted, 1); // Only the Dropped one.
let all = profile_load_all(&conn).unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].key, "style/active");
}
fn setup_db() -> Arc<Mutex<Connection>> {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(PROFILE_INIT_SQL).unwrap();
@@ -94,6 +345,12 @@ fn render_profile_context_formats_correctly() {
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1002.0,
state: FacetState::Active,
stability: 0.0,
user_state: UserState::Auto,
evidence_refs: vec![],
class: None,
cue_families: None,
},
ProfileFacet {
facet_id: "f-2".into(),
@@ -105,6 +362,12 @@ fn render_profile_context_formats_correctly() {
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 1000.0,
state: FacetState::Active,
stability: 0.0,
user_state: UserState::Auto,
evidence_refs: vec![],
class: None,
cue_families: None,
},
];
+45 -1
View File
@@ -9,6 +9,7 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::config::Config;
use crate::openhuman::memory::tree::canonicalize::{
chat::{self, ChatBatch},
@@ -23,6 +24,7 @@ use crate::openhuman::memory::tree::score::{self, ScoreResult, ScoringConfig};
use crate::openhuman::memory::tree::store;
use crate::openhuman::memory::tree::types::SourceKind;
use crate::openhuman::memory::tree::util::redact::redact;
use std::time::{SystemTime, UNIX_EPOCH};
/// Outcome of one ingest call.
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -154,6 +156,25 @@ async fn persist(
canonical: CanonicalisedSource,
) -> Result<IngestResult> {
let source_kind_for_store = canonical.metadata.source_kind;
// Capture body_preview before the canonical markdown is moved into the chunker.
// For email and document sources: last ≤ 2 048 chars of the canonical markdown
// are enough for signature parsing and similar lightweight subscribers. Chat
// sources are conversational and have no trailing structure worth scanning, so
// they get body_preview = None.
let body_preview: Option<String> = match source_kind_for_store {
SourceKind::Email | SourceKind::Document => {
let md = &canonical.markdown;
let len = md.len();
Some(if len <= 2048 {
md.clone()
} else {
md[len - 2048..].to_string()
})
}
_ => None,
};
let input = ChunkerInput {
source_kind: canonical.metadata.source_kind,
source_id: source_id.to_string(),
@@ -308,11 +329,34 @@ async fn persist(
jobs::wake_workers();
let chunk_ids: Vec<String> = staged.iter().map(|s| s.chunk.id.clone()).collect();
// Emit DocumentCanonicalized so Phase 2 producers (e.g. email-signature parser)
// can react to new canonicalised content. Non-fatal: ingest has already succeeded.
// `source_kind_for_store` is Copy so it is still accessible here after the closure.
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
publish_global(DomainEvent::DocumentCanonicalized {
source_id: source_id.to_string(),
source_kind: source_kind_for_store.as_str().to_string(),
chunks_written: written,
chunk_ids: chunk_ids.clone(),
canonicalized_at: now_secs,
body_preview,
});
tracing::debug!(
"[memory::tree::ingest] published DocumentCanonicalized source_id={} chunks={}",
source_id,
written
);
Ok(IngestResult {
source_id: source_id.to_string(),
chunks_written: written,
chunks_dropped: dropped,
chunk_ids: staged.iter().map(|s| s.chunk.id.clone()).collect(),
chunk_ids,
already_ingested: false,
})
}
@@ -42,6 +42,7 @@ use std::sync::Arc;
use super::inert::InertSummariser;
use super::{Summariser, SummaryContext, SummaryInput, SummaryOutput};
use crate::openhuman::learning::extract::summary_facets::{self, StructuredSummary};
use crate::openhuman::memory::tree::chat::{ChatPrompt, ChatProvider};
use crate::openhuman::memory::tree::types::approx_token_count;
@@ -78,12 +79,19 @@ pub struct LlmSummariserConfig {
/// Model identifier (e.g. `summarization-v1` for cloud, `qwen2.5:0.5b`
/// or `llama3.1:8b` for local Ollama). Diagnostic / log only.
pub model: String,
/// When `true` (the default), the summariser appends a structured facet
/// extraction request to the prompt and parses the resulting JSON block.
/// Discovered facets are routed to the learning candidate buffer.
/// Set to `false` to restore the plain-text-only behaviour for A/B testing
/// or debugging.
pub structured_facet_extraction: bool,
}
impl Default for LlmSummariserConfig {
fn default() -> Self {
Self {
model: "qwen2.5:0.5b".to_string(),
structured_facet_extraction: true,
}
}
}
@@ -108,9 +116,12 @@ impl LlmSummariser {
}
/// Build the chat prompt sent to the provider for a given seal.
///
/// When `structured_facet_extraction` is enabled the system prompt includes
/// an instruction to emit a fenced `json` block after the prose summary.
fn build_prompt(&self, prompt_body: &str, budget: u32) -> ChatPrompt {
ChatPrompt {
system: system_prompt(budget),
system: system_prompt(budget, self.cfg.structured_facet_extraction),
user: prompt_body.to_string(),
temperature: 0.0,
kind: "memory_tree::summarise",
@@ -193,7 +204,46 @@ impl Summariser for LlmSummariser {
}
};
let (content, token_count) = clamp_to_budget(raw.trim(), effective_budget);
// When structured_facet_extraction is enabled, attempt to split the response
// into a prose summary and an optional JSON block. On parse failure, the
// prose is used as-is and zero facets are emitted (fail-soft).
let summary_text: &str;
if self.cfg.structured_facet_extraction {
let (prose, maybe_structured) = split_structured_response(raw.trim());
summary_text = prose;
match maybe_structured {
Some(Ok(parsed)) => {
tracing::debug!(
"[learning::extract::summary] source_id={} facets_emitted={}",
ctx.tree_id,
parsed.facets.len()
);
summary_facets::route_facets_to_buffer(&parsed, ctx.tree_id);
}
Some(Err(e)) => {
log::warn!(
"[tree_source::summariser::llm] structured facet parse failed \
tree_id={} level={}: {e:#} — using raw prose, emitting 0 facets",
ctx.tree_id,
ctx.target_level
);
}
None => {
// No JSON block present — normal for content with no clear signals.
tracing::debug!(
"[tree_source::summariser::llm] no structured JSON block in response \
tree_id={} level={}",
ctx.tree_id,
ctx.target_level
);
}
}
} else {
summary_text = raw.trim();
}
let (content, token_count) = clamp_to_budget(summary_text, effective_budget);
log::debug!(
"[tree_source::summariser::llm] sealed tree_id={} level={} inputs={} tokens={}",
ctx.tree_id,
@@ -235,19 +285,95 @@ fn build_user_prompt(inputs: &[SummaryInput], per_input_cap_tokens: u32) -> Stri
out
}
/// System prompt. Length isn't templated in — empirically, telling
/// instruction-tuned models "stay under N tokens" makes them produce
/// curt, generic output even when the input has plenty of substance.
/// Output is clamped post-generation by [`clamp_to_budget`] in the
/// caller, so we don't need the model to self-police length.
fn system_prompt(_budget: u32) -> String {
"You are a precise summariser. Summarise the user-provided contributions into a \
/// System prompt.
///
/// When `structured_facets` is `true`, appends instructions for the model to
/// emit a fenced `json` block after the prose summary containing any clearly
/// evidenced facets.
///
/// Length isn't templated in — empirically, telling instruction-tuned models
/// "stay under N tokens" makes them produce curt, generic output even when the
/// input has plenty of substance. Output is clamped post-generation by
/// [`clamp_to_budget`] in the caller.
fn system_prompt(_budget: u32, structured_facets: bool) -> String {
let base = "You are a precise summariser. Summarise the user-provided contributions into a \
single cohesive passage that preserves concrete facts, decisions, \
and temporal ordering. Do not invent facts.\n\
\n\
Return only the summary text. No commentary, no preamble, no headings, \
no markdown wrappers, no JSON — just the prose summary."
.to_string()
Return the summary text first.";
if !structured_facets {
return format!(
"{base} No commentary, no preamble, no headings, \
no markdown wrappers, no JSON — just the prose summary."
);
}
format!(
"{base}\n\
\n\
After the summary, output a JSON object as the second part of your response, \
fenced in a ```json block:\n\
\n\
```json\n\
{{\n\
\"summary\": \"<the summary text you just produced>\",\n\
\"facets\": [\n\
{{\n\
\"class\": \"style|identity|tooling|veto|goal\",\n\
\"key\": \"<canonical slug>\",\n\
\"value\": \"<detected value>\",\n\
\"evidence_chunks\": [\"<chunk_id>\", \"...\"],\n\
\"confidence\": 0.0,\n\
\"cue_family\": \"explicit|structural|behavioral\"\n\
}}\n\
]\n\
}}\n\
```\n\
\n\
Rules:\n\
- Only include facets that are clearly evidenced in the content above.\n\
- Each facet must cite at least one chunk_id from this batch (the id in brackets \
before each contribution, e.g. [chunk-abc]).\n\
- Use canonical keys: verbosity, format, name, timezone, role, package_manager, \
lang, framework, runtime, etc.\n\
- Cap the facets array at 8 items per call. Skip the array entirely (emit \
facets: []) if no clear evidence.\n\
- No commentary outside the prose summary and the JSON block."
)
}
/// Split a raw LLM response that may contain a trailing fenced `json` block.
///
/// Returns `(prose, Option<parse_result>)` where:
/// - `prose` is the text before the ` ```json ` fence (trimmed), or the full
/// raw text when no fence is present.
/// - The second element is `None` when no fence was found, or
/// `Some(Ok(StructuredSummary))` / `Some(Err(…))` on parse success/failure.
fn split_structured_response(raw: &str) -> (&str, Option<anyhow::Result<StructuredSummary>>) {
// Look for the opening ` ```json ` fence.
const OPEN_FENCE: &str = "```json";
const CLOSE_FENCE: &str = "```";
let Some(fence_start) = raw.find(OPEN_FENCE) else {
return (raw, None);
};
let prose = raw[..fence_start].trim();
let after_open = &raw[fence_start + OPEN_FENCE.len()..];
// Find the closing fence.
let json_str = if let Some(close_pos) = after_open.find(CLOSE_FENCE) {
after_open[..close_pos].trim()
} else {
// No closing fence — treat everything after the open as JSON.
after_open.trim()
};
let result = serde_json::from_str::<StructuredSummary>(json_str)
.map_err(|e| anyhow::anyhow!("structured summary JSON parse error: {e}"));
(prose, Some(result))
}
/// Truncate to the caller's token budget using the same ~4 chars/token
@@ -346,18 +472,74 @@ mod tests {
#[test]
fn system_prompt_describes_plain_text_output() {
// Budget is no longer templated into the prompt — models
// produced overly curt output when told to "stay under N tokens".
// The clamp in `clamp_to_budget` handles enforcement instead.
let p = system_prompt(4096);
// When structured_facets is disabled, the prompt asks for plain prose.
let p = system_prompt(4096, false);
assert!(!p.contains("4096"));
assert!(!p.contains("Stay well under"));
// Output is plain prose, not JSON.
assert!(!p.contains("\"summary\""));
assert!(p.to_lowercase().contains("no commentary"));
assert!(p.to_lowercase().contains("no json"));
}
#[test]
fn extends_prompt_when_flag_enabled() {
let p = system_prompt(4096, true);
// When structured_facets is true, the prompt should contain the JSON fence instruction.
assert!(
p.contains("```json"),
"should contain JSON fence instruction"
);
assert!(p.contains("\"facets\""), "should mention the facets array");
assert!(
p.contains("evidence_chunks"),
"should mention evidence_chunks"
);
assert!(
p.contains("canonical keys"),
"should specify canonical keys"
);
}
#[test]
fn parses_well_formed_response() {
let raw = "The user prefers pnpm.\n\n\
```json\n\
{\"summary\": \"The user prefers pnpm.\", \"facets\": [\
{\"class\": \"tooling\", \"key\": \"package_manager\", \
\"value\": \"pnpm\", \"evidence_chunks\": [\"c1\"], \
\"confidence\": 0.9, \"cue_family\": \"explicit\"}\
]}\n\
```";
let (prose, maybe) = split_structured_response(raw);
assert!(
prose.contains("prefers pnpm"),
"prose should precede the JSON block"
);
let parsed = maybe
.expect("should find JSON block")
.expect("should parse");
assert_eq!(parsed.facets.len(), 1);
assert_eq!(parsed.facets[0].key, "package_manager");
}
#[test]
fn gracefully_falls_back_on_invalid_json() {
let raw = "Summary text.\n\n```json\nnot valid json\n```";
let (prose, maybe) = split_structured_response(raw);
assert!(prose.contains("Summary"), "prose should be extracted");
let result = maybe.expect("fence found");
assert!(result.is_err(), "invalid JSON should produce Err");
}
#[test]
fn respects_disabled_flag() {
let p = system_prompt(4096, false);
assert!(
!p.contains("```json"),
"disabled flag must omit JSON instruction"
);
}
#[test]
fn clamp_to_budget_no_op_when_under() {
let (out, t) = clamp_to_budget("short", 1000);
@@ -409,13 +591,21 @@ mod tests {
}
}
/// Helper config with structured facet extraction disabled for legacy tests.
fn no_facets_cfg() -> LlmSummariserConfig {
LlmSummariserConfig {
model: "qwen2.5:0.5b".into(),
structured_facet_extraction: false,
}
}
#[tokio::test]
async fn empty_inputs_yield_empty_summary_without_provider_call() {
// All inputs are blank → prompt body is empty → the summariser
// short-circuits and returns an empty output without invoking the
// chat provider.
let provider = std::sync::Arc::new(StubProvider::ok("never returned"));
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider.clone());
let s = LlmSummariser::new(no_facets_cfg(), provider.clone());
let inputs = vec![sample_input("a", " "), sample_input("b", "")];
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
assert!(out.content.is_empty());
@@ -433,7 +623,7 @@ mod tests {
// InertSummariser's concatenate+truncate behaviour (content
// present, entities empty).
let provider = std::sync::Arc::new(StubProvider::err("simulated"));
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider);
let s = LlmSummariser::new(no_facets_cfg(), provider);
let inputs = vec![sample_input("a", "alice decided to ship friday")];
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
assert!(out.content.contains("alice decided to ship"));
@@ -446,7 +636,7 @@ mod tests {
// Provider returns plain text; summariser uses it verbatim
// (after trim) and clamps to the budget.
let provider = std::sync::Arc::new(StubProvider::ok("alice decided to ship friday\n"));
let s = LlmSummariser::new(LlmSummariserConfig::default(), provider.clone());
let s = LlmSummariser::new(no_facets_cfg(), provider.clone());
let inputs = vec![sample_input("a", "alice ships friday")];
let out = s.summarise(&inputs, &test_ctx()).await.unwrap();
assert_eq!(out.content, "alice decided to ship friday");
@@ -457,9 +647,11 @@ mod tests {
#[test]
fn build_prompt_carries_body_and_kind_tag() {
let provider = std::sync::Arc::new(StubProvider::ok("hi"));
// With structured_facet_extraction disabled, expect plain-text prompt.
let s = LlmSummariser::new(
LlmSummariserConfig {
model: "llama3.1:8b".into(),
structured_facet_extraction: false,
},
provider,
);
@@ -142,7 +142,10 @@ pub fn build_summariser(config: &Config) -> Arc<dyn Summariser> {
model
);
Arc::new(llm::LlmSummariser::new(
llm::LlmSummariserConfig { model },
llm::LlmSummariserConfig {
model,
structured_facet_extraction: true,
},
provider,
))
}
+310
View File
@@ -0,0 +1,310 @@
//! Phase 4 integration test for agent self-learning (#566).
//!
//! Exercises the full end-to-end pipeline:
//! 1. Initialize a temp memory client + facet cache + stability detector + profile_md renderer.
//! 2. Push 5 candidates spanning multiple classes.
//! 3. Call `StabilityDetector::rebuild()`.
//! 4. Verify `CacheRebuilt` event fired.
//! 5. Verify `ProfileMdRenderer` wrote to `PROFILE.md` with expected blocks + bullets.
//! 6. Call `learning.pin_facet` RPC for one entry → re-rebuild → verify it stays Active.
//! 7. Call `learning.forget_facet` for another → verify it disappears from PROFILE.md.
//! 8. Call `learning.list_facets` → verify shape.
//!
//! Run with: `cargo test --test learning_phase4_integration_test`
use std::sync::Arc;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use openhuman_core::openhuman::learning::cache::{class_prefix, FacetCache};
use openhuman_core::openhuman::learning::candidate::{
self as candidate, CueFamily, EvidenceRef, FacetClass, LearningCandidate,
};
use openhuman_core::openhuman::learning::profile_md_renderer::ProfileMdRenderer;
use openhuman_core::openhuman::learning::stability_detector::StabilityDetector;
use openhuman_core::openhuman::memory::store::profile::{
FacetState, FacetType, ProfileFacet, UserState, PROFILE_INIT_SQL,
};
use parking_lot::Mutex;
use rusqlite::Connection;
use tempfile::TempDir;
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
fn make_candidate(
class: FacetClass,
key: &str,
value: &str,
cue: CueFamily,
now: f64,
) -> LearningCandidate {
LearningCandidate {
class,
key: key.into(),
value: value.into(),
cue_family: cue,
evidence: EvidenceRef::Episodic { episodic_id: 1 },
initial_confidence: 0.9,
observed_at: now,
}
}
/// Build a test harness backed by an in-memory SQLite database.
///
/// Uses the global candidate buffer (Phase 3 public API). To avoid test
/// interference the harness immediately drains the buffer before pushing
/// its own candidates.
struct TestHarness {
cache: Arc<FacetCache>,
detector: StabilityDetector,
renderer: Arc<ProfileMdRenderer>,
workspace: TempDir,
}
impl TestHarness {
fn new() -> Self {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(PROFILE_INIT_SQL).unwrap();
let conn = Arc::new(Mutex::new(conn));
let cache = Arc::new(FacetCache::new(Arc::clone(&conn)));
let workspace = TempDir::new().unwrap();
let renderer = Arc::new(ProfileMdRenderer::new(
Arc::clone(&cache),
workspace.path().to_path_buf(),
));
// Drain any stale candidates from prior tests so they don't affect
// this test's results.
let _ = candidate::global().drain();
let detector = StabilityDetector::new(FacetCache::new(conn));
TestHarness {
cache,
detector,
renderer,
workspace,
}
}
}
// ── The integration test ──────────────────────────────────────────────────────
#[test]
fn phase4_end_to_end_pin_forget_profile_md_list() {
let harness = TestHarness::new();
let now = now_secs();
// Step 1: Push 5 candidates spanning multiple classes.
// Push enough explicit evidence per candidate to clear τ_promote = 1.5.
let candidates = [
(FacetClass::Style, "verbosity", "terse"),
(FacetClass::Identity, "name", "Alice"),
(FacetClass::Tooling, "editor", "neovim"),
(
FacetClass::Goal,
"primary",
"Ship the agent self-learning feature",
),
(FacetClass::Veto, "no-emojis", "avoid emojis in output"),
];
for (class, key, value) in &candidates {
// Push 5 explicit candidates per key so the aggregated stability clears τ_promote.
for j in 0..5_u32 {
candidate::global().push(make_candidate(
*class,
key,
value,
CueFamily::Explicit,
now - f64::from(j),
));
}
}
// Step 2: Run rebuild.
let outcome = harness.detector.rebuild(now).unwrap();
assert!(
outcome.added >= 1,
"rebuild should have added rows: {outcome:?}"
);
// Step 3: Verify all 5 candidates are now Active.
let active = harness.cache.list_active().unwrap();
assert!(
active.len() >= 5,
"expected ≥ 5 active rows, got {}: {:?}",
active.len(),
active.iter().map(|f| &f.key).collect::<Vec<_>>(),
);
// Step 4: Render PROFILE.md via the renderer.
harness.renderer.render().unwrap();
let profile_path = harness.workspace.path().join("PROFILE.md");
assert!(profile_path.exists(), "PROFILE.md was not created");
let profile_content = std::fs::read_to_string(&profile_path).unwrap();
// Verify expected blocks.
assert!(
profile_content.contains("## Style"),
"Style block missing:\n{profile_content}"
);
assert!(
profile_content.contains("## Identity"),
"Identity block missing:\n{profile_content}"
);
assert!(
profile_content.contains("## Tooling"),
"Tooling block missing:\n{profile_content}"
);
assert!(
profile_content.contains("## Goals"),
"Goals block missing:\n{profile_content}"
);
assert!(
profile_content.contains("## Vetoes"),
"Vetoes block missing:\n{profile_content}"
);
// Style facet should be in **key**: value format.
assert!(
profile_content.contains("terse"),
"style/verbosity=terse missing:\n{profile_content}"
);
// Goal should render as plain sentence.
assert!(
profile_content.contains("Ship the agent self-learning feature"),
"goal sentence missing:\n{profile_content}"
);
// Step 5: Pin the style/verbosity facet.
let style_key = format!("{}/verbosity", class_prefix(FacetClass::Style));
harness
.cache
.set_user_state(&style_key, UserState::Pinned)
.unwrap();
// Re-rebuild with no new candidates (only decay applies).
let outcome2 = harness.detector.rebuild(now).unwrap();
// The pinned row should remain Active regardless of decay.
let pinned_facet = harness.cache.get(&style_key).unwrap();
assert!(pinned_facet.is_some(), "pinned row must survive re-rebuild");
let pf = pinned_facet.unwrap();
assert_eq!(
pf.state,
FacetState::Active,
"pinned row must stay Active after re-rebuild"
);
assert_eq!(pf.user_state, UserState::Pinned);
let _ = outcome2; // used for assertion comment
// Re-render and verify pin marker.
harness.renderer.render().unwrap();
let profile_after_pin = std::fs::read_to_string(&profile_path).unwrap();
assert!(
profile_after_pin.contains("*(pinned)*"),
"pinned marker missing from PROFILE.md:\n{profile_after_pin}"
);
// Step 6: Forget the identity/name facet.
let identity_key = format!("{}/name", class_prefix(FacetClass::Identity));
let mut identity_facet = harness.cache.get(&identity_key).unwrap().unwrap();
identity_facet.user_state = UserState::Forgotten;
identity_facet.state = FacetState::Dropped;
harness.cache.upsert(&identity_facet).unwrap();
// Re-render.
harness.renderer.render().unwrap();
let profile_after_forget = std::fs::read_to_string(&profile_path).unwrap();
// identity/name=Alice should no longer appear in the visible sections.
// (The identity block placeholder renders if all identity rows are non-active.)
let identity_block_start = profile_after_forget
.find("<!-- openhuman:identity:start -->")
.unwrap_or(0);
let identity_block_end = profile_after_forget
.find("<!-- openhuman:identity:end -->")
.unwrap_or(profile_after_forget.len());
let identity_block_content = &profile_after_forget[identity_block_start..identity_block_end];
assert!(
!identity_block_content.contains("Alice")
|| identity_block_content.contains("*(no entries yet)*"),
"forgotten facet Alice must not appear in identity block:\n{identity_block_content}"
);
// Step 7: list_facets — verify shape.
let all_active = harness.cache.list_active().unwrap();
// The style facet should be present (pinned, Active).
assert!(
all_active.iter().any(|f| f.key == style_key),
"pinned style/verbosity must appear in list_active"
);
// The forgotten identity/name should NOT appear in Active list.
assert!(
!all_active.iter().any(|f| f.key == identity_key),
"forgotten identity/name must not appear in list_active"
);
// Verify the connected-accounts block is untouched (never written by renderer).
assert!(
!profile_after_forget.contains("<!-- openhuman:connected-accounts:start -->"),
"connected-accounts block should not be written by the renderer"
);
println!("phase4_end_to_end_pin_forget_profile_md_list: all assertions passed");
}
// ── list_facets unit-level smoke test (no RPC server needed) ─────────────────
#[test]
fn list_facets_cache_direct_active_vs_all() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(PROFILE_INIT_SQL).unwrap();
let cache = FacetCache::new(Arc::new(Mutex::new(conn)));
let make = |id: &str, key: &str, state: FacetState| ProfileFacet {
facet_id: id.into(),
facet_type: FacetType::Preference,
key: key.into(),
value: "val".into(),
confidence: 0.8,
evidence_count: 2,
source_segment_ids: None,
first_seen_at: 1000.0,
last_seen_at: 2000.0,
state,
stability: 2.0,
user_state: UserState::Auto,
evidence_refs: vec![],
class: key.split('/').next().map(str::to_string),
cue_families: None,
};
cache
.upsert(&make("f1", "style/verbosity", FacetState::Active))
.unwrap();
cache
.upsert(&make("f2", "style/tone", FacetState::Provisional))
.unwrap();
cache
.upsert(&make("f3", "identity/name", FacetState::Dropped))
.unwrap();
let active = cache.list_active().unwrap();
assert_eq!(
active.len(),
1,
"list_active should only return Active rows"
);
assert_eq!(active[0].key, "style/verbosity");
let all = cache.list_all().unwrap();
// All 3 rows (Active + Provisional + Dropped).
assert_eq!(all.len(), 3, "list_all should return all rows");
}