use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use chrono::{DateTime, NaiveTime, Utc};
use serde::{Deserialize, Serialize};
use crate::core::curate::{
ClusterCoherenceCluster, ClusterCoherenceInput, is_peer_evidence_source_ref,
silhouette_agglomerative_clusters,
};
use crate::core::outcome::{OutcomeRecordOptions, OutcomeRecordReport, record_outcome};
use crate::core::query_miss_cluster::KNOWLEDGE_GAP_MIN_CLUSTER_MISSES;
use crate::db::{
CreateAuditInput, CreateCurationCandidateInput, CreateLearningObservationInput, DbConnection,
StoredAuditEntry, StoredCurationCandidate, StoredFeedbackEvent, StoredLearningObservation,
StoredMemory, audit_actions,
};
use crate::models::{
DomainError, ExperimentOutcome, ExperimentOutcomeStatus, ExperimentSafetyBoundary,
LearningObservation, LearningObservationSignal, LearningTargetKind,
};
use crate::search::{HashEmbedder, simhash::cosine_similarity};
const DEFAULT_LEARN_CLUSTER_COHERENCE_THRESHOLD: f32 =
crate::curate::cluster_coherence::DEFAULT_CLUSTER_COHERENCE_THRESHOLD as f32;
const DEFAULT_LEARN_CLUSTER_SILHOUETTE_CUTOFF: f32 =
crate::curate::cluster_coherence::DEFAULT_CLUSTER_SILHOUETTE_CUTOFF as f32;
const LEARN_CLUSTER_COHERENCE_THRESHOLD_KEY: &str = "learn.cluster_coherence_threshold";
pub const DEFAULT_QUERY_MISS_RETENTION_DAYS: u64 = 30;
const DEFAULT_LEARN_GAPS_LIMIT: u32 = 10;
const LIKELY_COVERED_SIMILARITY: f32 = 0.55;
pub const LEARN_AGENDA_SCHEMA_V1: &str = "ee.learn.agenda.v1";
pub const LEARN_UNCERTAINTY_SCHEMA_V1: &str = "ee.learn.uncertainty.v1";
pub const LEARN_SUMMARY_SCHEMA_V1: &str = "ee.learn.summary.v1";
pub const LEARN_GAPS_SCHEMA_V1: &str = "ee.learn.gaps.v1";
pub const LEARN_GAPS_NO_MISS_DATA: &str = "learn_gaps_no_miss_data";
pub const LEARN_GAPS_RETENTION_SHORT: &str = "learn_gaps_retention_short";
pub const LEARN_CLUSTER_SCHEMA_V1: &str = "ee.learn.cluster.v1";
pub const LEARN_EXPERIMENT_PROPOSAL_SCHEMA_V1: &str = "ee.learn.experiment_proposal.v1";
pub const LEARN_EXPERIMENT_RUN_SCHEMA_V1: &str = "ee.learn.experiment_run.v1";
pub const LEARN_OBSERVE_SCHEMA_V1: &str = "ee.learn.observe.v1";
pub const LEARN_CLOSE_SCHEMA_V1: &str = "ee.learn.close.v1";
pub const LEARN_DOWNSTREAM_EFFECTS_SCHEMA_V1: &str = "ee.learn.downstream_effects.v1";
#[derive(Clone, Debug, Default)]
pub struct LearnAgendaOptions {
pub workspace: PathBuf,
pub limit: u32,
pub topic: Option<String>,
pub include_resolved: bool,
pub sort: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AgendaItem {
pub id: String,
pub topic: String,
pub gap_description: String,
pub priority: u8,
pub uncertainty: f64,
pub source: String,
pub sample_ids: Vec<String>,
pub status: String,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LearnAgendaReport {
pub schema: String,
pub items: Vec<AgendaItem>,
pub total_gaps: u32,
pub high_priority_count: u32,
pub resolved_count: u32,
pub generated_at: String,
}
impl LearnAgendaReport {
#[must_use]
pub fn to_json(&self) -> String {
crate::core::serialize_or_error(self)
}
}
pub fn show_agenda(options: &LearnAgendaOptions) -> Result<LearnAgendaReport, DomainError> {
let clusters = load_learning_clusters(&options.workspace, options.topic.as_deref())?;
let mut items = clusters
.iter()
.filter(|cluster| options.include_resolved || cluster.status() != "resolved")
.map(|cluster| cluster.agenda_item())
.collect::<Vec<_>>();
match options.sort.as_str() {
"uncertainty" => items.sort_by(|left, right| {
right
.uncertainty
.total_cmp(&left.uncertainty)
.then_with(|| right.priority.cmp(&left.priority))
.then_with(|| left.topic.cmp(&right.topic))
.then_with(|| left.id.cmp(&right.id))
}),
"recency" => items.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| right.priority.cmp(&left.priority))
.then_with(|| left.topic.cmp(&right.topic))
.then_with(|| left.id.cmp(&right.id))
}),
_ => items.sort_by(|left, right| {
right
.priority
.cmp(&left.priority)
.then_with(|| right.uncertainty.total_cmp(&left.uncertainty))
.then_with(|| left.topic.cmp(&right.topic))
.then_with(|| left.id.cmp(&right.id))
}),
}
let total_gaps = items.len() as u32;
let high_priority_count = items.iter().filter(|item| item.priority >= 70).count() as u32;
let resolved_count = items
.iter()
.filter(|item| item.status == "resolved")
.count() as u32;
items.truncate(options.limit as usize);
Ok(LearnAgendaReport {
schema: LEARN_AGENDA_SCHEMA_V1.to_string(),
items,
total_gaps,
high_priority_count,
resolved_count,
generated_at: stable_learning_generated_at(),
})
}
#[derive(Clone, Debug, Default)]
pub struct LearnUncertaintyOptions {
pub workspace: PathBuf,
pub limit: u32,
pub min_uncertainty: f64,
pub kind: Option<String>,
pub low_confidence: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UncertaintyItem {
pub memory_id: String,
pub content: String,
pub content_truncated: bool,
pub kind: String,
pub uncertainty: f64,
pub confidence: f64,
pub retrieval_count: u32,
pub last_accessed: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LearnUncertaintyReport {
pub schema: String,
pub items: Vec<UncertaintyItem>,
pub mean_uncertainty: f64,
pub high_uncertainty_count: u32,
pub sampling_candidates: u32,
pub generated_at: String,
}
impl LearnUncertaintyReport {
#[must_use]
pub fn to_json(&self) -> String {
crate::core::serialize_or_error(self)
}
}
pub fn show_uncertainty(
options: &LearnUncertaintyOptions,
) -> Result<LearnUncertaintyReport, DomainError> {
let clusters = load_learning_clusters(&options.workspace, options.kind.as_deref())?;
let mut items = clusters
.iter()
.map(|cluster| cluster.uncertainty_item())
.filter(|item| item.uncertainty >= options.min_uncertainty)
.filter(|item| !options.low_confidence || item.confidence < 0.5)
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.uncertainty
.total_cmp(&left.uncertainty)
.then_with(|| left.memory_id.cmp(&right.memory_id))
});
let mean_uncertainty = if items.is_empty() {
0.0
} else {
rounded_metric(items.iter().map(|item| item.uncertainty).sum::<f64>() / items.len() as f64)
};
let high_uncertainty_count = items.iter().filter(|item| item.uncertainty >= 0.7).count() as u32;
let sampling_candidates = items.len() as u32;
items.truncate(options.limit as usize);
Ok(LearnUncertaintyReport {
schema: LEARN_UNCERTAINTY_SCHEMA_V1.to_string(),
items,
mean_uncertainty,
high_uncertainty_count,
sampling_candidates,
generated_at: stable_learning_generated_at(),
})
}
#[derive(Clone, Debug, Default)]
pub struct LearnSummaryOptions {
pub workspace: PathBuf,
pub period: String,
pub since: Option<String>,
pub detailed: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LearningSummary {
pub period: String,
pub memories_created: u32,
pub memories_promoted: u32,
pub memories_demoted: u32,
pub memories_demoted_via_decay: u32,
pub memories_tombstoned_via_decay: u32,
pub rules_learned: u32,
pub rules_validated: u32,
pub gaps_identified: u32,
pub gaps_resolved: u32,
pub observations_recorded: u32,
pub candidates_proposed: u32,
pub applied_rules: u32,
pub harmful_feedback_count: u32,
pub net_knowledge_delta: i32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LearningEvent {
pub event_type: String,
pub description: String,
pub impact: String,
pub occurred_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LearnSummaryReport {
pub schema: String,
pub summary: LearningSummary,
pub events: Vec<LearningEvent>,
pub generated_at: String,
}
impl LearnSummaryReport {
#[must_use]
pub fn to_json(&self) -> String {
crate::core::serialize_or_error(self)
}
}
pub fn show_summary(options: &LearnSummaryOptions) -> Result<LearnSummaryReport, DomainError> {
let effective_since = learn_summary_effective_since(options)?;
let since = effective_since
.as_deref()
.map(parse_learn_since)
.transpose()?;
let snapshot = load_learning_snapshot(&options.workspace)?;
let events = snapshot
.feedback_events
.iter()
.filter(|event| timestamp_at_or_after(&event.created_at, since.as_ref()))
.cloned()
.collect::<Vec<_>>();
let ledger_observation_count = snapshot
.learning_observations
.iter()
.filter(|observation| timestamp_at_or_after(&observation.observed_at, since.as_ref()))
.count() as u32;
let clusters = build_learning_clusters(&snapshot, None, &events);
let harmful_feedback_count = events
.iter()
.filter(|event| is_negative_signal(&event.signal))
.count() as u32;
let decay_audit_entries = snapshot
.audit_entries
.iter()
.filter(|entry| timestamp_at_or_after(&entry.timestamp, since.as_ref()))
.collect::<Vec<_>>();
let memories_demoted_via_decay = decay_audit_entries
.iter()
.filter(|entry| entry.action == audit_actions::MEMORY_DECAY_DEMOTE)
.count() as u32;
let memories_tombstoned_via_decay = decay_audit_entries
.iter()
.filter(|entry| entry.action == audit_actions::MEMORY_DECAY_TOMBSTONE)
.count() as u32;
let memories_created = snapshot
.memories
.values()
.filter(|memory| timestamp_at_or_after(&memory.created_at, since.as_ref()))
.count() as u32;
let candidates = snapshot
.curation_candidates
.iter()
.filter(|candidate| timestamp_at_or_after(&candidate.created_at, since.as_ref()))
.collect::<Vec<_>>();
let candidates_proposed = candidates.len() as u32;
let applied_rules = candidates
.iter()
.filter(|candidate| {
candidate.candidate_type == "rule"
&& (candidate.status == "applied" || candidate.applied_at.is_some())
})
.count() as u32;
let rules_validated = candidates
.iter()
.filter(|candidate| candidate.candidate_type == "rule" && candidate.status == "approved")
.count() as u32;
let rules_learned = candidates
.iter()
.filter(|candidate| candidate.candidate_type == "rule")
.count() as u32;
let gaps_identified = clusters.len() as u32;
let gaps_resolved = clusters
.iter()
.filter(|cluster| cluster.status() == "resolved")
.count() as u32;
let memories_promoted = events
.iter()
.filter(|event| {
matches!(
event.signal.as_str(),
"positive" | "helpful" | "confirmation"
)
})
.count() as u32;
let memories_demoted = harmful_feedback_count.saturating_add(memories_demoted_via_decay);
let net_knowledge_delta = i32::try_from(memories_promoted + rules_learned + rules_validated)
.unwrap_or(i32::MAX)
- i32::try_from(memories_demoted.saturating_add(memories_tombstoned_via_decay))
.unwrap_or(i32::MAX);
let mut learning_events = Vec::new();
if options.detailed {
for event in events.iter().rev().take(10) {
learning_events.push(LearningEvent {
event_type: event.signal.clone(),
description: event
.reason
.clone()
.unwrap_or_else(|| format!("Observed {} feedback.", event.signal)),
impact: feedback_impact(&event.signal).to_string(),
occurred_at: event.created_at.clone(),
});
}
learning_events.sort_by(|left, right| {
right
.occurred_at
.cmp(&left.occurred_at)
.then_with(|| left.event_type.cmp(&right.event_type))
});
}
Ok(LearnSummaryReport {
schema: LEARN_SUMMARY_SCHEMA_V1.to_string(),
summary: LearningSummary {
period: options
.since
.clone()
.unwrap_or_else(|| options.period.clone()),
memories_created,
memories_promoted,
memories_demoted,
memories_demoted_via_decay,
memories_tombstoned_via_decay,
rules_learned,
rules_validated,
gaps_identified,
gaps_resolved,
observations_recorded: ledger_observation_count.max(events.len() as u32),
candidates_proposed,
applied_rules,
harmful_feedback_count,
net_knowledge_delta,
},
events: learning_events,
generated_at: stable_learning_generated_at(),
})
}
fn learn_summary_effective_since(
options: &LearnSummaryOptions,
) -> Result<Option<String>, DomainError> {
if let Some(since) = options.since.as_ref() {
return Ok(Some(since.clone()));
}
learn_summary_period_since(&options.period, Utc::now())
}
fn learn_summary_period_since(
period: &str,
now: DateTime<Utc>,
) -> Result<Option<String>, DomainError> {
match period.trim() {
"all" => Ok(None),
"today" => Ok(Some(
now.date_naive()
.and_time(NaiveTime::MIN)
.and_utc()
.to_rfc3339(),
)),
"week" => Ok(Some((now - chrono::Duration::days(7)).to_rfc3339())),
"month" => Ok(Some((now - chrono::Duration::days(30)).to_rfc3339())),
other => Err(DomainError::Usage {
message: format!(
"unsupported learn summary period `{other}`; expected today, week, month, or all"
),
repair: Some(
"Use --period today, --period week, --period month, or --period all.".to_string(),
),
}),
}
}
fn timestamp_at_or_after(raw: &str, since: Option<&DateTime<Utc>>) -> bool {
let Some(since) = since else {
return true;
};
DateTime::parse_from_rfc3339(raw)
.map(|timestamp| timestamp.with_timezone(&Utc) >= *since)
.unwrap_or(false)
}
#[derive(Clone, Debug)]
pub struct LearnGapsOptions {
pub workspace: PathBuf,
pub since: Option<String>,
pub limit: u32,
}
impl Default for LearnGapsOptions {
fn default() -> Self {
Self {
workspace: PathBuf::from("."),
since: None,
limit: DEFAULT_LEARN_GAPS_LIMIT,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LearnGapOriginDemand {
pub origin: String,
pub miss_count: u32,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LearnGapNearestEvidence {
pub memory_id: String,
pub content_preview: String,
pub reason: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LearnGapRememberTemplate {
pub suggested_level: String,
pub suggested_kind: String,
pub suggested_tags: Vec<String>,
pub content_skeleton: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LearnGapCluster {
pub cluster_id: String,
pub query_hash: String,
pub query_hashes: Vec<String>,
pub demand_score: f64,
pub miss_count: u32,
pub first_seen_at: String,
pub last_seen_at: String,
pub origins: Vec<LearnGapOriginDemand>,
pub reasons: Vec<String>,
pub representative_redacted_queries: Vec<String>,
pub nearest_existing_evidence: Vec<LearnGapNearestEvidence>,
pub nearest_existing_evidence_status: String,
pub remember_template: LearnGapRememberTemplate,
pub matching_agenda_item: Option<String>,
pub suggested_command: String,
#[serde(default = "default_gap_status")]
pub status: String,
#[serde(default)]
pub covered_by: Option<String>,
#[serde(default)]
pub covered_by_created_at: Option<String>,
}
fn default_gap_status() -> String {
"open".to_owned()
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LearnGapsDegradation {
pub code: String,
pub severity: String,
pub message: String,
pub repair: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LearnGapsReport {
pub schema: String,
pub workspace_id: String,
pub retention_days: u64,
pub requested_since: Option<String>,
pub effective_since: String,
pub scanned_miss_count: u32,
pub cluster_count: u32,
pub gaps: Vec<LearnGapCluster>,
pub degraded: Vec<LearnGapsDegradation>,
pub generated_at: String,
}
impl LearnGapsReport {
#[must_use]
pub fn to_json(&self) -> String {
crate::core::serialize_or_error(self)
}
}
#[derive(Clone, Debug)]
struct ParsedQueryMiss {
query_hash: String,
cluster_key: String,
reason: String,
origin: String,
timestamp: DateTime<Utc>,
timestamp_raw: String,
representative_redacted_queries: Vec<String>,
}
#[derive(Clone, Debug)]
struct QueryMissAccumulator {
cluster_key: String,
query_hashes: BTreeSet<String>,
miss_count: u32,
origins: BTreeMap<String, u32>,
reasons: BTreeSet<String>,
representative_redacted_queries: BTreeSet<String>,
first_seen_at: DateTime<Utc>,
first_seen_raw: String,
last_seen_at: DateTime<Utc>,
last_seen_raw: String,
}
impl QueryMissAccumulator {
fn new(cluster_key: &str, miss: &ParsedQueryMiss) -> Self {
let mut origins = BTreeMap::new();
origins.insert(miss.origin.clone(), 1);
let mut reasons = BTreeSet::new();
if !miss.reason.trim().is_empty() {
reasons.insert(miss.reason.clone());
}
let representative_redacted_queries = miss
.representative_redacted_queries
.iter()
.cloned()
.collect::<BTreeSet<_>>();
Self {
cluster_key: cluster_key.to_string(),
query_hashes: BTreeSet::from([miss.query_hash.clone()]),
miss_count: 1,
origins,
reasons,
representative_redacted_queries,
first_seen_at: miss.timestamp,
first_seen_raw: miss.timestamp_raw.clone(),
last_seen_at: miss.timestamp,
last_seen_raw: miss.timestamp_raw.clone(),
}
}
fn record(&mut self, miss: &ParsedQueryMiss) {
self.miss_count = self.miss_count.saturating_add(1);
self.query_hashes.insert(miss.query_hash.clone());
*self.origins.entry(miss.origin.clone()).or_insert(0) += 1;
if !miss.reason.trim().is_empty() {
self.reasons.insert(miss.reason.clone());
}
self.representative_redacted_queries
.extend(miss.representative_redacted_queries.iter().cloned());
if miss.timestamp < self.first_seen_at {
self.first_seen_at = miss.timestamp;
self.first_seen_raw = miss.timestamp_raw.clone();
}
if miss.timestamp > self.last_seen_at {
self.last_seen_at = miss.timestamp;
self.last_seen_raw = miss.timestamp_raw.clone();
}
}
fn origin_demand(&self) -> Vec<LearnGapOriginDemand> {
let mut origins = self
.origins
.iter()
.map(|(origin, miss_count)| LearnGapOriginDemand {
origin: origin.clone(),
miss_count: *miss_count,
})
.collect::<Vec<_>>();
origins.sort_by(|left, right| {
right
.miss_count
.cmp(&left.miss_count)
.then_with(|| left.origin.cmp(&right.origin))
});
origins
}
fn query_hashes(&self) -> Vec<String> {
self.query_hashes.iter().cloned().collect()
}
}
pub fn show_gaps(options: &LearnGapsOptions) -> Result<LearnGapsReport, DomainError> {
let snapshot = load_learning_snapshot(&options.workspace)?;
let retention_days = resolve_query_miss_retention_days(&options.workspace)?;
let requested_since = options
.since
.as_deref()
.map(parse_learn_since)
.transpose()?;
let parsed_misses = snapshot
.audit_entries
.iter()
.filter_map(parse_query_miss_audit_entry)
.collect::<Vec<_>>();
let newest_miss = parsed_misses
.iter()
.map(|miss| miss.timestamp)
.max()
.unwrap_or_else(Utc::now);
let retention_cutoff = query_miss_retention_cutoff(newest_miss, retention_days)?;
let (effective_since, mut degraded) =
learn_gaps_effective_since(requested_since, retention_cutoff);
let filtered = parsed_misses
.iter()
.filter(|miss| miss.timestamp >= effective_since)
.collect::<Vec<_>>();
if filtered.is_empty() {
degraded.push(learn_gaps_no_miss_data_degradation());
}
let mut by_cluster: BTreeMap<String, QueryMissAccumulator> = BTreeMap::new();
for (cluster_key, miss) in query_miss_cluster_assignments(&filtered) {
by_cluster
.entry(cluster_key.clone())
.and_modify(|accumulator| accumulator.record(miss))
.or_insert_with(|| QueryMissAccumulator::new(&cluster_key, miss));
}
let repeated_clusters = by_cluster
.values()
.filter(|cluster| cluster.miss_count >= KNOWLEDGE_GAP_MIN_CLUSTER_MISSES)
.collect::<Vec<_>>();
let newest_cluster_ts = repeated_clusters
.iter()
.map(|cluster| cluster.last_seen_at)
.max()
.unwrap_or(newest_miss);
let mut gaps = repeated_clusters
.into_iter()
.map(|cluster| {
learn_gap_cluster(
cluster,
newest_cluster_ts,
retention_days,
&snapshot.curation_candidates,
&snapshot.memories,
&options.workspace,
)
})
.collect::<Vec<_>>();
gaps.sort_by(|left, right| {
right
.demand_score
.total_cmp(&left.demand_score)
.then_with(|| right.miss_count.cmp(&left.miss_count))
.then_with(|| left.query_hash.cmp(&right.query_hash))
});
let cluster_count = gaps.len() as u32;
let limit = options.limit.max(1) as usize;
gaps.truncate(limit);
Ok(LearnGapsReport {
schema: LEARN_GAPS_SCHEMA_V1.to_string(),
workspace_id: snapshot.workspace_id,
retention_days,
requested_since: options.since.clone(),
effective_since: effective_since.to_rfc3339(),
scanned_miss_count: filtered.len() as u32,
cluster_count,
gaps,
degraded,
generated_at: stable_learning_generated_at(),
})
}
fn parse_learn_since(raw: &str) -> Result<DateTime<Utc>, DomainError> {
DateTime::parse_from_rfc3339(raw)
.map(|timestamp| timestamp.with_timezone(&Utc))
.map_err(|error| DomainError::Usage {
message: format!("Invalid --since `{raw}`: expected RFC3339 timestamp ({error})"),
repair: Some("Use --since 2026-01-31T00:00:00Z.".to_string()),
})
}
fn learn_gaps_effective_since(
requested_since: Option<DateTime<Utc>>,
retention_cutoff: DateTime<Utc>,
) -> (DateTime<Utc>, Vec<LearnGapsDegradation>) {
match requested_since {
Some(since) if since < retention_cutoff => (
retention_cutoff,
vec![learn_gaps_retention_short_degradation(
since,
retention_cutoff,
)],
),
Some(since) => (since, Vec::new()),
None => (retention_cutoff, Vec::new()),
}
}
fn query_miss_retention_cutoff(
newest_miss: DateTime<Utc>,
retention_days: u64,
) -> Result<DateTime<Utc>, DomainError> {
let retention_seconds =
retention_days
.checked_mul(24 * 60 * 60)
.ok_or_else(|| DomainError::Configuration {
message: "Query-miss retention exceeds supported duration range.".to_string(),
repair: Some("Use a smaller [search].query_miss_retention_days value.".to_string()),
})?;
let retention_seconds =
i64::try_from(retention_seconds).map_err(|_| DomainError::Configuration {
message: "Query-miss retention exceeds supported duration range.".to_string(),
repair: Some("Use a smaller [search].query_miss_retention_days value.".to_string()),
})?;
let retention_duration = chrono::Duration::try_seconds(retention_seconds).ok_or_else(|| {
DomainError::Configuration {
message: "Query-miss retention exceeds supported duration range.".to_string(),
repair: Some("Use a smaller [search].query_miss_retention_days value.".to_string()),
}
})?;
newest_miss
.checked_sub_signed(retention_duration)
.ok_or_else(|| DomainError::Configuration {
message: "Query-miss retention cutoff is outside supported time range.".to_string(),
repair: Some("Use a smaller [search].query_miss_retention_days value.".to_string()),
})
}
fn parse_query_miss_audit_entry(entry: &StoredAuditEntry) -> Option<ParsedQueryMiss> {
if entry.action != audit_actions::SEARCH_MISS_RECORDED {
return None;
}
let details = entry.details.as_deref()?;
let value: serde_json::Value = serde_json::from_str(details).ok()?;
let query_hash = value.get("queryHash")?.as_str()?.trim().to_string();
if query_hash.is_empty() {
return None;
}
let timestamp = DateTime::parse_from_rfc3339(&entry.timestamp)
.ok()?
.with_timezone(&Utc);
let reason = value
.get("reason")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|reason| !reason.is_empty())
.unwrap_or("unknown")
.to_string();
let origin = query_miss_origin(&value);
let representative_redacted_queries = query_miss_representative_queries(&value);
let cluster_key = query_miss_cluster_key(&query_hash, &representative_redacted_queries);
Some(ParsedQueryMiss {
query_hash,
cluster_key,
reason,
origin,
timestamp,
timestamp_raw: entry.timestamp.clone(),
representative_redacted_queries,
})
}
fn query_miss_origin(value: &serde_json::Value) -> String {
let origin = value
.get("origin")
.or_else(|| value.get("source"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|origin| !origin.is_empty())
.unwrap_or("search");
match origin {
"ask" => "ask".to_string(),
"search" => "search".to_string(),
other => normalize_topic(other),
}
}
fn query_miss_representative_queries(value: &serde_json::Value) -> Vec<String> {
let mut queries = BTreeSet::new();
for key in [
"redactedQuery",
"queryPreview",
"representativeRedactedQuery",
"representativeQuery",
] {
if let Some(query) = value
.get(key)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|query| !query.is_empty())
{
queries.insert(redact_learning_public_ref(query));
}
}
for key in ["redactedQueries", "representativeRedactedQueries"] {
if let Some(values) = value.get(key).and_then(serde_json::Value::as_array) {
for value in values {
if let Some(query) = value
.as_str()
.map(str::trim)
.filter(|query| !query.is_empty())
{
queries.insert(redact_learning_public_ref(query));
}
}
}
}
queries.into_iter().collect()
}
fn query_miss_cluster_assignments<'a>(
misses: &[&'a ParsedQueryMiss],
) -> Vec<(String, &'a ParsedQueryMiss)> {
let mut assignments = Vec::with_capacity(misses.len());
let mut redacted_inputs = Vec::new();
let mut redacted_by_id: BTreeMap<String, &'a ParsedQueryMiss> = BTreeMap::new();
let embedder = HashEmbedder::default_256();
for (index, miss) in misses.iter().enumerate() {
let Some(query) = miss.representative_redacted_queries.first() else {
assignments.push((miss.cluster_key.clone(), *miss));
continue;
};
let normalized = normalized_query_cluster_key(query);
if normalized.is_empty() {
assignments.push((miss.cluster_key.clone(), *miss));
continue;
}
let member_id = format!(
"query_miss_{}_{}",
stable_suffix(
"learn_gap_query_miss",
&format!("{}:{}:{}", miss.query_hash, miss.timestamp_raw, normalized),
16
),
index
);
redacted_by_id.insert(member_id.clone(), *miss);
redacted_inputs.push(ClusterCoherenceInput {
memory_id: member_id,
embedding: embedder.embed_sync(&normalized),
});
}
if redacted_inputs.is_empty() {
return assignments;
}
let report = silhouette_agglomerative_clusters(
&redacted_inputs,
DEFAULT_LEARN_CLUSTER_COHERENCE_THRESHOLD,
);
let mut assigned_redacted = BTreeSet::new();
for cluster in report.clusters {
let mut member_ids = cluster.member_memory_ids;
member_ids.sort();
let cluster_key = format!(
"query_agglomerative:{}",
stable_suffix("learn_gap_agglomerative", &member_ids.join("|"), 20)
);
for member_id in member_ids {
if let Some(miss) = redacted_by_id.get(&member_id).copied() {
assigned_redacted.insert(member_id);
assignments.push((cluster_key.clone(), miss));
}
}
}
for (member_id, miss) in redacted_by_id {
if !assigned_redacted.contains(&member_id) {
assignments.push((miss.cluster_key.clone(), miss));
}
}
assignments
}
fn query_miss_cluster_key(query_hash: &str, representative_queries: &[String]) -> String {
representative_queries
.first()
.map(|query| normalized_query_cluster_key(query))
.filter(|key| !key.is_empty())
.map(|key| format!("query:{key}"))
.unwrap_or_else(|| format!("hash:{query_hash}"))
}
fn normalized_query_cluster_key(query: &str) -> String {
let mut tokens = query
.split(|character: char| !character.is_ascii_alphanumeric())
.map(str::trim)
.filter(|token| !token.is_empty())
.map(str::to_ascii_lowercase)
.filter(|token| !is_query_cluster_stopword(token))
.collect::<Vec<_>>();
tokens.sort();
tokens.dedup();
tokens.join(" ")
}
fn is_query_cluster_stopword(token: &str) -> bool {
matches!(
token,
"a" | "an"
| "and"
| "are"
| "do"
| "does"
| "for"
| "how"
| "i"
| "in"
| "is"
| "of"
| "on"
| "or"
| "the"
| "to"
| "what"
| "with"
)
}
fn learn_gap_cluster(
cluster: &QueryMissAccumulator,
newest_cluster_ts: DateTime<Utc>,
retention_days: u64,
candidates: &[StoredCurationCandidate],
memories: &BTreeMap<String, StoredMemory>,
workspace: &Path,
) -> LearnGapCluster {
let representative_redacted_queries = cluster
.representative_redacted_queries
.iter()
.take(5)
.cloned()
.collect::<Vec<_>>();
let remember_template = infer_remember_template(
&cluster.cluster_key,
representative_redacted_queries.first(),
);
let (nearest_existing_evidence, nearest_existing_evidence_status, top_hit) =
nearest_gap_evidence(&representative_redacted_queries, memories);
let (status, covered_by, covered_by_created_at) = match &top_hit {
Some((memory_id, created_at, similarity))
if *similarity >= LIKELY_COVERED_SIMILARITY
&& created_at.as_str() > cluster.last_seen_raw.as_str() =>
{
(
"likely_covered".to_owned(),
Some(memory_id.clone()),
Some(created_at.clone()),
)
}
_ => (default_gap_status(), None, None),
};
let suggested_command = format!(
"ee remember --workspace {} --level {} --kind {} {} --json",
shell_quote(&workspace.display().to_string()),
remember_template.suggested_level,
remember_template.suggested_kind,
shell_quote(&remember_template.content_skeleton)
);
LearnGapCluster {
cluster_id: format!(
"gap_{}",
stable_suffix("learn_gap", &cluster.cluster_key, 20)
),
query_hash: cluster
.query_hashes
.first()
.cloned()
.unwrap_or_else(|| cluster.cluster_key.clone()),
query_hashes: cluster.query_hashes(),
demand_score: query_miss_demand_score(cluster, newest_cluster_ts, retention_days),
miss_count: cluster.miss_count,
first_seen_at: cluster.first_seen_raw.clone(),
last_seen_at: cluster.last_seen_raw.clone(),
origins: cluster.origin_demand(),
reasons: cluster.reasons.iter().cloned().collect(),
representative_redacted_queries,
nearest_existing_evidence,
nearest_existing_evidence_status,
remember_template,
matching_agenda_item: matching_open_agenda_item(candidates, &cluster.query_hashes),
suggested_command,
status,
covered_by,
covered_by_created_at,
}
}
type NearestGapTopHit = Option<(String, String, f32)>;
fn nearest_gap_evidence(
representative_queries: &[String],
memories: &BTreeMap<String, StoredMemory>,
) -> (Vec<LearnGapNearestEvidence>, String, NearestGapTopHit) {
let Some(query) = representative_queries.first() else {
return (
Vec::new(),
"unavailable_raw_query_not_persisted".to_string(),
None,
);
};
let live_memories = memories
.values()
.filter(|memory| memory.tombstoned_at.is_none())
.collect::<Vec<_>>();
if live_memories.is_empty() {
return (Vec::new(), "unavailable_no_live_memories".to_string(), None);
}
let embedder = HashEmbedder::default_256();
let query_embedding = embedder.embed_sync(query);
let mut scored = live_memories
.into_iter()
.filter_map(|memory| {
let memory_embedding = embedder.embed_sync(&memory.content);
cosine_similarity(&query_embedding, &memory_embedding)
.map(|similarity| (memory, similarity))
})
.collect::<Vec<_>>();
if scored.is_empty() {
return (
Vec::new(),
"unavailable_no_comparable_embeddings".to_string(),
None,
);
}
scored.sort_by(
|(left_memory, left_similarity), (right_memory, right_similarity)| {
right_similarity
.total_cmp(left_similarity)
.then_with(|| left_memory.id.cmp(&right_memory.id))
},
);
let top_hit = scored
.first()
.map(|(memory, similarity)| (memory.id.clone(), memory.created_at.clone(), *similarity));
let evidence = scored
.into_iter()
.take(3)
.map(|(memory, similarity)| LearnGapNearestEvidence {
memory_id: memory.id.clone(),
content_preview: preview_text(&memory.content, 160),
reason: format!("hash_embedder_cosine_similarity={similarity:.3}"),
})
.collect();
(evidence, "hash_embedder_scan".to_string(), top_hit)
}
fn query_miss_demand_score(
cluster: &QueryMissAccumulator,
newest_cluster_ts: DateTime<Utc>,
retention_days: u64,
) -> f64 {
let age_seconds = newest_cluster_ts
.signed_duration_since(cluster.last_seen_at)
.num_seconds()
.max(0) as f64;
let age_days = age_seconds / 86_400.0;
let decay = 0.5_f64.powf(age_days / retention_days.max(1) as f64);
rounded_metric(f64::from(cluster.miss_count) * decay)
}
fn matching_open_agenda_item(
candidates: &[StoredCurationCandidate],
query_hashes: &BTreeSet<String>,
) -> Option<String> {
candidates
.iter()
.filter(|candidate| curation_candidate_is_open(candidate))
.find(|candidate| {
query_hashes
.iter()
.any(|query_hash| curation_candidate_mentions(candidate, query_hash))
})
.map(|candidate| candidate.id.clone())
}
fn curation_candidate_is_open(candidate: &StoredCurationCandidate) -> bool {
!matches!(
candidate.status.as_str(),
"applied" | "approved" | "rejected" | "dismissed" | "retired" | "tombstoned"
) && candidate.applied_at.is_none()
}
fn curation_candidate_mentions(candidate: &StoredCurationCandidate, query_hash: &str) -> bool {
[
Some(candidate.id.as_str()),
Some(candidate.reason.as_str()),
candidate.proposed_content.as_deref(),
candidate.source_id.as_deref(),
candidate.derivation_source_refs_json.as_deref(),
candidate.derivation_metadata_json.as_deref(),
]
.into_iter()
.flatten()
.any(|value| value.contains(query_hash))
}
fn infer_remember_template(
query_hash: &str,
representative_query: Option<&String>,
) -> LearnGapRememberTemplate {
let Some(query) = representative_query.map(String::as_str) else {
return LearnGapRememberTemplate {
suggested_level: "semantic".to_string(),
suggested_kind: "fact".to_string(),
suggested_tags: vec!["knowledge-gap".to_string(), "query-miss".to_string()],
content_skeleton: format!(
"Record the missing fact behind repeated query-miss hash {}: <fact, source, and evidence>.",
query_hash_preview(query_hash)
),
};
};
let normalized = normalize_query_template_text(query);
let padded = format!(" {normalized} ");
if normalized.starts_with("how do i ")
|| normalized.starts_with("how to ")
|| padded.contains(" command ")
|| padded.contains(" run ")
{
LearnGapRememberTemplate {
suggested_level: "procedural".to_string(),
suggested_kind: "rule".to_string(),
suggested_tags: vec!["knowledge-gap".to_string(), "command".to_string()],
content_skeleton: format!(
"When asked `{query}`, record the command/procedure: <steps, prerequisites, and verification>."
),
}
} else if normalized.contains("what broke")
|| padded.contains(" failure ")
|| padded.contains(" incident ")
|| padded.contains(" regression ")
{
LearnGapRememberTemplate {
suggested_level: "episodic".to_string(),
suggested_kind: "failure".to_string(),
suggested_tags: vec!["knowledge-gap".to_string(), "failure".to_string()],
content_skeleton: format!(
"For `{query}`, record the failure episode: <symptom, cause, fix, and proof>."
),
}
} else {
LearnGapRememberTemplate {
suggested_level: "semantic".to_string(),
suggested_kind: "fact".to_string(),
suggested_tags: vec!["knowledge-gap".to_string(), "fact".to_string()],
content_skeleton: format!(
"For `{query}`, record the missing fact: <answer, scope, source, and caveats>."
),
}
}
}
fn normalize_query_template_text(query: &str) -> String {
query
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
fn query_hash_preview(query_hash: &str) -> String {
query_hash.chars().take(12).collect()
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
fn learn_gaps_no_miss_data_degradation() -> LearnGapsDegradation {
LearnGapsDegradation {
code: LEARN_GAPS_NO_MISS_DATA.to_string(),
severity: "info".to_string(),
message: "No query-miss audit rows were found inside the effective window.".to_string(),
repair: "Run ee search or ee ask normally; low-utility searches will populate the miss ledger without storing raw query text.".to_string(),
}
}
fn learn_gaps_retention_short_degradation(
requested_since: DateTime<Utc>,
retention_cutoff: DateTime<Utc>,
) -> LearnGapsDegradation {
LearnGapsDegradation {
code: LEARN_GAPS_RETENTION_SHORT.to_string(),
severity: "info".to_string(),
message: format!(
"Requested --since {} predates the configured query-miss retention window; scanning from {}.",
requested_since.to_rfc3339(),
retention_cutoff.to_rfc3339()
),
repair: "Increase [search].query_miss_retention_days or EE_QUERY_MISS_RETENTION_DAYS before the ledger is pruned if a longer window is required.".to_string(),
}
}
fn resolve_query_miss_retention_days(workspace: &Path) -> Result<u64, DomainError> {
let config_path = workspace.join(".ee").join("config.toml");
let configured = match crate::config::read_workspace_config_contents(workspace) {
Ok(Some(contents)) => {
let config = crate::config::ConfigFile::parse(&contents).map_err(|error| {
DomainError::Configuration {
message: format!(
"Failed to parse workspace query-miss retention config {}: {error}",
config_path.display()
),
repair: Some(
"Fix [search].query_miss_retention_days in .ee/config.toml and rerun ee learn gaps --json.".to_owned(),
),
}
})?;
config.search.query_miss_retention_days
}
Ok(None) => None,
Err(error) => {
return Err(DomainError::Configuration {
message: format!(
"Failed to read workspace query-miss retention config {}: {error}",
config_path.display()
),
repair: Some("Check .ee/config.toml and rerun ee learn gaps --json.".to_owned()),
});
}
};
if let Some(raw) = crate::config::read_env_var(crate::config::EnvVar::QueryMissRetentionDays) {
return raw
.parse::<u64>()
.map_err(|error| DomainError::Configuration {
message: format!("Invalid EE_QUERY_MISS_RETENTION_DAYS `{raw}`: {error}"),
repair: Some(
"Set EE_QUERY_MISS_RETENTION_DAYS to a non-negative integer.".to_owned(),
),
});
}
Ok(configured.unwrap_or(DEFAULT_QUERY_MISS_RETENTION_DAYS))
}
#[derive(Clone, Debug)]
pub struct LearnClusterOptions {
pub workspace: PathBuf,
pub threshold: Option<f32>,
pub min_cluster_size: usize,
pub include_singletons: bool,
pub level: Option<String>,
pub kind: Option<String>,
}
impl Default for LearnClusterOptions {
fn default() -> Self {
Self {
workspace: PathBuf::from("."),
threshold: None,
min_cluster_size: 3,
include_singletons: false,
level: None,
kind: None,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct LearnClusterReport {
pub schema: String,
pub workspace_id: String,
pub threshold: f32,
pub min_cluster_size: usize,
pub memory_count: usize,
pub clustered_memory_count: usize,
pub cluster_count: usize,
pub clusters: Vec<ClusterCoherenceCluster>,
pub degradations: Vec<String>,
pub generated_at: String,
}
pub fn analyze_clusters(options: &LearnClusterOptions) -> Result<LearnClusterReport, DomainError> {
let threshold = resolve_learn_cluster_threshold(&options.workspace, options.threshold)?;
let snapshot = load_learning_snapshot(&options.workspace)?;
let mut memories = snapshot
.memories
.values()
.filter(|memory| memory.tombstoned_at.is_none())
.filter(|memory| {
options
.level
.as_ref()
.is_none_or(|level| &memory.level == level)
})
.filter(|memory| {
options
.kind
.as_ref()
.is_none_or(|kind| &memory.kind == kind)
})
.cloned()
.collect::<Vec<_>>();
memories.sort_by(|left, right| left.id.cmp(&right.id));
let embedder = HashEmbedder::default_256();
let inputs = memories
.iter()
.map(|memory| ClusterCoherenceInput {
memory_id: memory.id.clone(),
embedding: embedder.embed_sync(&cluster_embedding_text(&snapshot, memory)),
})
.collect::<Vec<_>>();
let raw_report = silhouette_agglomerative_clusters(&inputs, threshold);
let mut degradations = raw_report.degradations;
let clusters = raw_report
.clusters
.into_iter()
.filter(|cluster| {
options.include_singletons
|| cluster.member_memory_ids.len() >= options.min_cluster_size
})
.collect::<Vec<_>>();
if !inputs.is_empty() && clusters.is_empty() {
degradations.push("degraded.clustering_threshold_too_strict".to_owned());
}
let clustered_memory_count = clusters
.iter()
.map(|cluster| cluster.member_memory_ids.len())
.sum();
let report = LearnClusterReport {
schema: LEARN_CLUSTER_SCHEMA_V1.to_owned(),
workspace_id: snapshot.workspace_id,
threshold: raw_report.threshold,
min_cluster_size: options.min_cluster_size,
memory_count: inputs.len(),
clustered_memory_count,
cluster_count: clusters.len(),
clusters,
degradations,
generated_at: stable_learning_generated_at(),
};
log_learn_cluster_events(&report);
Ok(report)
}
fn cluster_embedding_text(snapshot: &LearningSnapshot, memory: &StoredMemory) -> String {
let tags = snapshot
.memory_tags
.get(&memory.id)
.map(|tags| tags.join(" "))
.unwrap_or_default();
format!(
"level:{}\nkind:{}\ntags:{}\ncontent:{}",
memory.level, memory.kind, tags, memory.content
)
}
fn resolve_learn_cluster_threshold(
workspace: &Path,
cli_threshold: Option<f32>,
) -> Result<f32, DomainError> {
if let Some(threshold) = cli_threshold {
return Ok(threshold);
}
let config_path = workspace.join(".ee").join("config.toml");
let contents = match crate::config::read_workspace_config_contents(workspace) {
Ok(Some(contents)) => contents,
Ok(None) => return Ok(DEFAULT_LEARN_CLUSTER_COHERENCE_THRESHOLD),
Err(error) => {
return Err(DomainError::Configuration {
message: format!(
"Failed to read workspace learn config {}: {error}",
config_path.display()
),
repair: Some("Check .ee/config.toml and rerun ee learn cluster --json.".to_owned()),
});
}
};
let config = crate::config::ConfigFile::parse(&contents).map_err(|error| {
DomainError::Configuration {
message: format!(
"Failed to parse workspace learn config {}: {error}",
config_path.display()
),
repair: Some(
"Fix [learn] in .ee/config.toml and rerun ee learn cluster --json.".to_owned(),
),
}
})?;
let configured = optional_learn_cluster_config_f32(
config.learn.cluster_coherence_threshold,
LEARN_CLUSTER_COHERENCE_THRESHOLD_KEY,
)?;
Ok(configured.unwrap_or(DEFAULT_LEARN_CLUSTER_COHERENCE_THRESHOLD))
}
fn optional_learn_cluster_config_f32(
value: Option<f64>,
key: &'static str,
) -> Result<Option<f32>, DomainError> {
let Some(value) = value else {
return Ok(None);
};
let value = value as f32;
if value.is_finite() {
Ok(Some(value))
} else {
Err(DomainError::Configuration {
message: format!("Config key `{key}` exceeds supported f32 range."),
repair: Some("Use a smaller finite number in [learn].".to_owned()),
})
}
}
fn log_learn_cluster_events(report: &LearnClusterReport) {
for cluster in &report.clusters {
let accepted = cluster.member_memory_ids.len() >= report.min_cluster_size
&& cluster
.silhouette_score
.is_some_and(|score| score >= DEFAULT_LEARN_CLUSTER_SILHOUETTE_CUTOFF);
let silhouette = cluster
.silhouette_score
.map_or(serde_json::Value::Null, serde_json::Value::from);
crate::obs::log_event(
crate::obs::TestEvent::new(
crate::obs::test_id_or("learn_cluster"),
crate::obs::EventKind::Note,
)
.with_field("event", "learn_cluster")
.with_field("candidate_id", cluster.cluster_id.clone())
.with_field(
"member_count",
serde_json::json!(cluster.member_memory_ids.len()),
)
.with_field("silhouette", silhouette)
.with_field("threshold", serde_json::json!(report.threshold))
.with_field("accepted", serde_json::json!(accepted)),
);
}
}
#[derive(Clone, Debug)]
pub struct LearnObserveOptions {
pub workspace: PathBuf,
pub database_path: Option<PathBuf>,
pub workspace_id: Option<String>,
pub experiment_id: String,
pub observation_id: Option<String>,
pub observed_at: Option<String>,
pub observer: Option<String>,
pub signal: LearningObservationSignal,
pub measurement_name: String,
pub measurement_value: Option<f64>,
pub evidence_ids: Vec<String>,
pub note: Option<String>,
pub redaction_status: Option<String>,
pub session_id: Option<String>,
pub event_id: Option<String>,
pub actor: Option<String>,
pub dry_run: bool,
}
#[derive(Clone, Debug)]
pub struct LearnCloseOptions {
pub workspace: PathBuf,
pub database_path: Option<PathBuf>,
pub workspace_id: Option<String>,
pub experiment_id: String,
pub outcome_id: Option<String>,
pub closed_at: Option<String>,
pub status: ExperimentOutcomeStatus,
pub decision_impact: String,
pub confidence_delta: f64,
pub priority_delta: i32,
pub promoted_artifact_ids: Vec<String>,
pub demoted_artifact_ids: Vec<String>,
pub safety_notes: Vec<String>,
pub audit_ids: Vec<String>,
pub session_id: Option<String>,
pub event_id: Option<String>,
pub actor: Option<String>,
pub dry_run: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LearnObserveReport {
pub schema: String,
pub status: String,
pub dry_run: bool,
pub observation: LearningObservation,
pub feedback: Option<OutcomeRecordReport>,
pub generated_at: String,
}
impl LearnObserveReport {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::json!({
"schema": self.schema,
"success": true,
"status": self.status,
"dryRun": self.dry_run,
"observation": learning_observation_public_json(&self.observation),
"feedback": self.feedback.as_ref().map(OutcomeRecordReport::data_json),
"generatedAt": self.generated_at,
})
}
#[must_use]
pub fn human_summary(&self) -> String {
let mut output = String::new();
if self.dry_run {
output.push_str("DRY RUN: Would attach learning observation\n\n");
} else {
output.push_str("Attached learning observation\n\n");
}
output.push_str(&format!(
" Experiment: {}\n",
self.observation.experiment_id
));
output.push_str(&format!(
" Observation: {}\n",
self.observation.observation_id
));
output.push_str(&format!(" Signal: {}\n", self.observation.signal.as_str()));
output.push_str(&format!(
" Evidence IDs: {}\n",
self.observation.evidence_ids.len()
));
if let Some(feedback) = &self.feedback {
if let Some(event_id) = &feedback.event_id {
output.push_str(&format!(" Feedback event: {event_id}\n"));
}
}
output
}
#[must_use]
pub fn toon_summary(&self) -> String {
format!(
"LEARN_OBSERVE|{}|{}|{}|evidence={}",
self.status,
self.observation.experiment_id,
self.observation.signal.as_str(),
self.observation.evidence_ids.len()
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LearnOutcomeEconomyScoreEffect {
pub affected_artifact_ids: Vec<String>,
pub promoted_count: usize,
pub demoted_count: usize,
pub utility_delta: f64,
pub confidence_delta: f64,
pub priority_delta: i32,
pub priority_multiplier: f64,
pub scoring_note: String,
}
impl LearnOutcomeEconomyScoreEffect {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::json!({
"affectedArtifactIds": redact_learning_public_refs(&self.affected_artifact_ids),
"promotedCount": self.promoted_count,
"demotedCount": self.demoted_count,
"utilityDelta": self.utility_delta,
"confidenceDelta": self.confidence_delta,
"priorityDelta": self.priority_delta,
"priorityMultiplier": self.priority_multiplier,
"scoringNote": self.scoring_note,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LearnOutcomeProcedureDriftEffect {
pub procedure_artifact_ids: Vec<String>,
pub drift_signal: String,
pub drift_score_delta: f64,
pub requires_revalidation: bool,
pub action: String,
}
impl LearnOutcomeProcedureDriftEffect {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::json!({
"procedureArtifactIds": redact_learning_public_refs(&self.procedure_artifact_ids),
"driftSignal": self.drift_signal,
"driftScoreDelta": self.drift_score_delta,
"requiresRevalidation": self.requires_revalidation,
"action": self.action,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LearnOutcomeTripwireFalseAlarmEffect {
pub tripwire_artifact_ids: Vec<String>,
pub false_alarm_cost_delta: u32,
pub confidence_delta: f64,
pub action: String,
pub scoring_note: String,
}
impl LearnOutcomeTripwireFalseAlarmEffect {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::json!({
"tripwireArtifactIds": redact_learning_public_refs(&self.tripwire_artifact_ids),
"falseAlarmCostDelta": self.false_alarm_cost_delta,
"confidenceDelta": self.confidence_delta,
"action": self.action,
"scoringNote": self.scoring_note,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LearnOutcomeSituationConfidenceEffect {
pub situation_artifact_ids: Vec<String>,
pub confidence_delta: f64,
pub confidence_direction: String,
pub action: String,
}
impl LearnOutcomeSituationConfidenceEffect {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::json!({
"situationArtifactIds": redact_learning_public_refs(&self.situation_artifact_ids),
"confidenceDelta": self.confidence_delta,
"confidenceDirection": self.confidence_direction,
"action": self.action,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LearnOutcomeDownstreamAudit {
pub durable_feedback_recorded: bool,
pub source_type: String,
pub source_id: String,
pub feedback_event_id: Option<String>,
pub audit_id: Option<String>,
pub silent_mutation: bool,
}
impl LearnOutcomeDownstreamAudit {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::json!({
"durableFeedbackRecorded": self.durable_feedback_recorded,
"sourceType": self.source_type,
"sourceId": redact_learning_public_ref(&self.source_id),
"feedbackEventId": self.feedback_event_id,
"auditId": self.audit_id,
"silentMutation": self.silent_mutation,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LearnOutcomeDownstreamEffects {
pub schema: &'static str,
pub mutation_mode: String,
pub economy_score: LearnOutcomeEconomyScoreEffect,
pub procedure_drift: LearnOutcomeProcedureDriftEffect,
pub tripwire_false_alarm: LearnOutcomeTripwireFalseAlarmEffect,
pub situation_confidence: LearnOutcomeSituationConfidenceEffect,
pub audit: LearnOutcomeDownstreamAudit,
}
impl LearnOutcomeDownstreamEffects {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::json!({
"schema": self.schema,
"mutationMode": self.mutation_mode,
"economyScore": self.economy_score.data_json(),
"procedureDrift": self.procedure_drift.data_json(),
"tripwireFalseAlarm": self.tripwire_false_alarm.data_json(),
"situationConfidence": self.situation_confidence.data_json(),
"audit": self.audit.data_json(),
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LearnCloseReport {
pub schema: String,
pub status: String,
pub dry_run: bool,
pub outcome: ExperimentOutcome,
pub feedback: Option<OutcomeRecordReport>,
pub downstream_effects: LearnOutcomeDownstreamEffects,
pub generated_at: String,
}
impl LearnCloseReport {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::json!({
"schema": self.schema,
"success": true,
"status": self.status,
"dryRun": self.dry_run,
"outcome": experiment_outcome_public_json(&self.outcome),
"feedback": self.feedback.as_ref().map(OutcomeRecordReport::data_json),
"downstreamEffects": self.downstream_effects.data_json(),
"generatedAt": self.generated_at,
})
}
#[must_use]
pub fn human_summary(&self) -> String {
let mut output = String::new();
if self.dry_run {
output.push_str("DRY RUN: Would close learning experiment\n\n");
} else {
output.push_str("Closed learning experiment\n\n");
}
output.push_str(&format!(" Experiment: {}\n", self.outcome.experiment_id));
output.push_str(&format!(" Outcome: {}\n", self.outcome.outcome_id));
output.push_str(&format!(" Status: {}\n", self.outcome.status.as_str()));
output.push_str(&format!(
" Promoted: {} | Demoted: {}\n",
self.outcome.promoted_artifact_ids.len(),
self.outcome.demoted_artifact_ids.len()
));
output.push_str(&format!(
" Downstream feedback: {}\n",
self.downstream_effects.mutation_mode
));
output.push_str(&format!(
" Tripwire false-alarm delta: {}\n",
self.downstream_effects
.tripwire_false_alarm
.false_alarm_cost_delta
));
if let Some(feedback) = &self.feedback {
if let Some(event_id) = &feedback.event_id {
output.push_str(&format!(" Feedback event: {event_id}\n"));
}
}
output
}
#[must_use]
pub fn toon_summary(&self) -> String {
format!(
"LEARN_CLOSE|{}|{}|{}|promoted={}|demoted={}|effect={}",
self.status,
self.outcome.experiment_id,
self.outcome.status.as_str(),
self.outcome.promoted_artifact_ids.len(),
self.outcome.demoted_artifact_ids.len(),
self.downstream_effects.mutation_mode
)
}
}
fn learning_observation_public_json(observation: &LearningObservation) -> serde_json::Value {
let redacted_evidence_ids = redact_learning_public_refs(&observation.evidence_ids);
let redaction_status = if redacted_evidence_ids != observation.evidence_ids
&& observation.redaction_status == "not_required"
{
"standard"
} else {
observation.redaction_status.as_str()
};
serde_json::json!({
"schema": observation.schema,
"observationId": observation.observation_id,
"experimentId": observation.experiment_id,
"observedAt": observation.observed_at,
"observer": observation.observer,
"signal": observation.signal.as_str(),
"measurementName": observation.measurement_name,
"measurementValue": observation.measurement_value,
"evidenceIds": redacted_evidence_ids,
"note": observation.note,
"redactionStatus": redaction_status,
})
}
fn experiment_outcome_public_json(outcome: &ExperimentOutcome) -> serde_json::Value {
serde_json::json!({
"schema": outcome.schema,
"outcomeId": outcome.outcome_id,
"experimentId": outcome.experiment_id,
"status": outcome.status.as_str(),
"closedAt": outcome.closed_at,
"decisionImpact": outcome.decision_impact,
"confidenceDelta": rounded_metric(outcome.confidence_delta),
"priorityDelta": outcome.priority_delta,
"promotedArtifactIds": redact_learning_public_refs(&outcome.promoted_artifact_ids),
"demotedArtifactIds": redact_learning_public_refs(&outcome.demoted_artifact_ids),
"safetyNotes": outcome.safety_notes,
"auditIds": redact_learning_public_refs(&outcome.audit_ids),
})
}
fn experiment_run_observation_preview_public_json(
observation: &ExperimentRunObservationPreview,
) -> serde_json::Value {
serde_json::json!({
"signal": observation.signal,
"measurementName": observation.measurement_name,
"measurementValue": observation.measurement_value,
"evidenceIds": redact_learning_public_refs(&observation.evidence_ids),
"note": observation.note,
})
}
fn experiment_run_outcome_preview_public_json(
outcome: &ExperimentRunOutcomePreview,
) -> serde_json::Value {
serde_json::json!({
"status": outcome.status,
"decisionImpact": outcome.decision_impact,
"confidenceDelta": outcome.confidence_delta,
"priorityDelta": outcome.priority_delta,
"promotedArtifactIds": redact_learning_public_refs(&outcome.promoted_artifact_ids),
"demotedArtifactIds": redact_learning_public_refs(&outcome.demoted_artifact_ids),
"safetyNotes": outcome.safety_notes,
})
}
fn redact_learning_public_refs(values: &[String]) -> Vec<String> {
values
.iter()
.map(|value| redact_learning_public_ref(value))
.collect()
}
fn redact_learning_public_ref(value: &str) -> String {
let secret_redacted = crate::policy::redact_secret_like_content(value).content;
redact_learning_public_path_like_segments(&secret_redacted)
}
fn redact_learning_public_path_like_segments(value: &str) -> String {
const REDACTED_PATH: &str = "[REDACTED_PATH]";
const PREFIXES: &[&str] = &[
"/Users/",
"/Volumes/",
"/private/",
"/var/",
"/tmp/",
"/home/",
"/data/",
"/dp/",
"/workspace/",
"/repo/",
"/etc/",
];
let mut output = String::with_capacity(value.len());
let mut cursor = 0;
while cursor < value.len() {
let Some((relative_index, _)) = value[cursor..].char_indices().find(|(_, c)| *c == '/')
else {
output.push_str(&value[cursor..]);
break;
};
let start = cursor + relative_index;
if !PREFIXES
.iter()
.any(|prefix| value[start..].starts_with(prefix))
{
output.push_str(&value[cursor..=start]);
cursor = start + 1;
continue;
}
output.push_str(&value[cursor..start]);
output.push_str(REDACTED_PATH);
cursor = value[start..]
.char_indices()
.find_map(|(index, c)| learning_public_path_boundary(c).then_some(start + index))
.unwrap_or(value.len());
}
output
}
fn learning_public_path_boundary(c: char) -> bool {
c.is_whitespace() || matches!(c, '?' | '#' | '"' | '\'' | ')' | ']' | '}' | ',' | ';')
}
pub fn observe_experiment(
options: &LearnObserveOptions,
) -> Result<LearnObserveReport, DomainError> {
let generated_at = Utc::now().to_rfc3339();
let experiment_id = require_text(
"experiment id",
&options.experiment_id,
"ee learn observe <experiment-id> --measurement-name verification --json",
)?;
let observation_id = options.observation_id.as_deref().map_or_else(
|| Ok(generate_learning_record_id("lobs")),
|value| {
require_text(
"observation id",
value,
"ee learn observe --observation-id lobs_...",
)
},
)?;
let observed_at = options.observed_at.as_deref().map_or_else(
|| Ok(generated_at.clone()),
|value| {
require_text(
"observed at",
value,
"ee learn observe --observed-at 2026-01-01T00:00:00Z",
)
},
)?;
let observer = options.observer.as_deref().map_or_else(
|| Ok("agent".to_string()),
|value| require_text("observer", value, "ee learn observe --observer MistySalmon"),
)?;
let measurement_name = require_text(
"measurement name",
&options.measurement_name,
"ee learn observe --measurement-name verification_status",
)?;
let evidence_ids = normalize_text_list(
"evidence id",
&options.evidence_ids,
"ee learn observe --evidence-id ev_001",
)?;
let note = normalize_optional_text(
"note",
options.note.as_deref(),
"ee learn observe --note 'dry-run passed'",
)?;
let redaction_status = options.redaction_status.as_deref().map_or_else(
|| Ok("not_required".to_string()),
|value| {
require_text(
"redaction status",
value,
"ee learn observe --redaction-status redacted",
)
},
)?;
let measurement_value = validate_optional_metric(options.measurement_value)?;
let mut observation = LearningObservation::new(
observation_id,
experiment_id.clone(),
observed_at,
observer,
measurement_name,
)
.with_signal(options.signal)
.with_redaction_status(redaction_status);
if let Some(value) = measurement_value {
observation = observation.with_measurement_value(value);
}
for evidence_id in evidence_ids {
observation = observation.with_evidence(evidence_id);
}
if let Some(note) = note {
observation = observation.with_note(note);
}
if options.dry_run {
return Ok(LearnObserveReport {
schema: LEARN_OBSERVE_SCHEMA_V1.to_string(),
status: "dry_run".to_string(),
dry_run: true,
observation,
feedback: None,
generated_at,
});
}
let database_path =
learning_database_path(options.database_path.as_deref(), &options.workspace);
let workspace_id = ensure_learning_workspace(
&database_path,
&options.workspace,
options.workspace_id.as_deref(),
)?;
let evidence_json = observation.data_json().to_string();
let feedback = record_outcome(&OutcomeRecordOptions {
database_path: &database_path,
target_type: "candidate".to_string(),
target_id: experiment_id,
workspace_id: Some(workspace_id.clone()),
signal: observation_signal_to_feedback(options.signal).to_string(),
weight: None,
source_type: "automated_check".to_string(),
source_id: Some(observation.observation_id.clone()),
reason: observation.note.clone(),
evidence_json: Some(evidence_json),
session_id: options.session_id.clone(),
event_id: options.event_id.clone(),
actor: options.actor.clone(),
agent_name: None,
dry_run: false,
harmful_per_source_per_hour: crate::core::outcome::DEFAULT_HARMFUL_PER_SOURCE_PER_HOUR,
harmful_burst_window_seconds: crate::core::outcome::DEFAULT_HARMFUL_BURST_WINDOW_SECONDS,
prompt_injection_guard: true,
})?;
persist_learning_observation(
&database_path,
&workspace_id,
&LearningObservationLedgerInput {
observation_kind: "experiment_observe",
source_type: "feedback_event",
source_id: feedback
.event_id
.clone()
.or_else(|| feedback.source_id.clone()),
target_type: "candidate",
target_id: &observation.experiment_id,
topic: Some(normalize_topic(&observation.experiment_id)),
signal: observation_signal_to_feedback(options.signal),
evidence_json: Some(observation.data_json().to_string()),
observed_at: &observation.observed_at,
},
)?;
let status = if feedback.status.as_str() == "already_recorded" {
"already_recorded"
} else {
"observed"
};
Ok(LearnObserveReport {
schema: LEARN_OBSERVE_SCHEMA_V1.to_string(),
status: status.to_string(),
dry_run: false,
observation,
feedback: Some(feedback),
generated_at,
})
}
pub fn close_experiment(options: &LearnCloseOptions) -> Result<LearnCloseReport, DomainError> {
let generated_at = Utc::now().to_rfc3339();
let experiment_id = require_text(
"experiment id",
&options.experiment_id,
"ee learn close <experiment-id> --status confirmed --decision-impact '...'",
)?;
let outcome_id = options.outcome_id.as_deref().map_or_else(
|| Ok(generate_learning_record_id("lout")),
|value| require_text("outcome id", value, "ee learn close --outcome-id lout_..."),
)?;
let closed_at = options.closed_at.as_deref().map_or_else(
|| Ok(generated_at.clone()),
|value| {
require_text(
"closed at",
value,
"ee learn close --closed-at 2026-01-01T00:00:00Z",
)
},
)?;
let decision_impact = require_text(
"decision impact",
&options.decision_impact,
"ee learn close --decision-impact 'confirmed promotion evidence'",
)?;
let promoted_artifact_ids = normalize_text_list(
"promoted artifact id",
&options.promoted_artifact_ids,
"ee learn close --promote-artifact mem_001",
)?;
let demoted_artifact_ids = normalize_text_list(
"demoted artifact id",
&options.demoted_artifact_ids,
"ee learn close --demote-artifact mem_002",
)?;
let safety_notes = normalize_text_list(
"safety note",
&options.safety_notes,
"ee learn close --safety-note 'no unsafe mutation'",
)?;
let audit_ids = normalize_text_list(
"audit id",
&options.audit_ids,
"ee learn close --audit-id audit_001",
)?;
let confidence_delta = validate_metric_delta(options.confidence_delta, "confidence delta")?;
let mut outcome = ExperimentOutcome::new(
outcome_id,
experiment_id.clone(),
closed_at,
decision_impact,
)
.with_status(options.status)
.with_confidence_delta(confidence_delta)
.with_priority_delta(options.priority_delta);
for artifact_id in promoted_artifact_ids {
outcome = outcome.with_promoted_artifact(artifact_id);
}
for artifact_id in demoted_artifact_ids {
outcome = outcome.with_demoted_artifact(artifact_id);
}
for note in safety_notes {
outcome = outcome.with_safety_note(note);
}
for audit_id in audit_ids {
outcome = outcome.with_audit_id(audit_id);
}
if options.dry_run {
let downstream_effects = downstream_effects_for_outcome(&outcome, true, None);
return Ok(LearnCloseReport {
schema: LEARN_CLOSE_SCHEMA_V1.to_string(),
status: "dry_run".to_string(),
dry_run: true,
outcome,
feedback: None,
downstream_effects,
generated_at,
});
}
let database_path =
learning_database_path(options.database_path.as_deref(), &options.workspace);
let workspace_id = ensure_learning_workspace(
&database_path,
&options.workspace,
options.workspace_id.as_deref(),
)?;
let evidence_json = outcome.data_json().to_string();
let feedback = record_outcome(&OutcomeRecordOptions {
database_path: &database_path,
target_type: "candidate".to_string(),
target_id: experiment_id,
workspace_id: Some(workspace_id.clone()),
signal: outcome_status_to_feedback(options.status).to_string(),
weight: None,
source_type: "outcome_observed".to_string(),
source_id: Some(outcome.outcome_id.clone()),
reason: Some(outcome.decision_impact.clone()),
evidence_json: Some(evidence_json),
session_id: options.session_id.clone(),
event_id: options.event_id.clone(),
actor: options.actor.clone(),
agent_name: None,
dry_run: false,
harmful_per_source_per_hour: crate::core::outcome::DEFAULT_HARMFUL_PER_SOURCE_PER_HOUR,
harmful_burst_window_seconds: crate::core::outcome::DEFAULT_HARMFUL_BURST_WINDOW_SECONDS,
prompt_injection_guard: true,
})?;
persist_learning_observation(
&database_path,
&workspace_id,
&LearningObservationLedgerInput {
observation_kind: "experiment_close",
source_type: "feedback_event",
source_id: feedback
.event_id
.clone()
.or_else(|| feedback.source_id.clone()),
target_type: "candidate",
target_id: &outcome.experiment_id,
topic: Some(normalize_topic(&outcome.experiment_id)),
signal: outcome_status_to_feedback(options.status),
evidence_json: Some(outcome.data_json().to_string()),
observed_at: &outcome.closed_at,
},
)?;
let status = if feedback.status.as_str() == "already_recorded" {
"already_recorded"
} else {
"closed"
};
let downstream_effects = downstream_effects_for_outcome(&outcome, false, Some(&feedback));
Ok(LearnCloseReport {
schema: LEARN_CLOSE_SCHEMA_V1.to_string(),
status: status.to_string(),
dry_run: false,
outcome,
feedback: Some(feedback),
downstream_effects,
generated_at,
})
}
fn learning_database_path(database_path: Option<&Path>, workspace: &Path) -> PathBuf {
database_path
.map(Path::to_path_buf)
.unwrap_or_else(|| workspace.join(".ee").join("ee.db"))
}
fn ensure_learning_workspace(
database_path: &Path,
workspace_path: &Path,
workspace_id: Option<&str>,
) -> Result<String, DomainError> {
if let Some(workspace_id) = workspace_id {
return require_text(
"workspace id",
workspace_id,
"ee learn observe --workspace-id wsp_...",
);
}
if !database_path.exists() {
return Err(crate::core::storeless_workspace_error(&database_path));
}
let connection =
DbConnection::open_file(database_path).map_err(|error| DomainError::Storage {
message: format!("Failed to open database: {error}"),
repair: Some("ee doctor".to_string()),
})?;
let normalized_workspace = normalize_workspace_path(workspace_path);
let workspace_path_text = normalized_workspace.to_string_lossy().into_owned();
let workspace_id = crate::core::workspace::ensure_bound_workspace(
&connection,
&stable_workspace_id(&workspace_path_text),
&[normalized_workspace.as_path(), workspace_path],
)?;
connection.close().map_err(|error| DomainError::Storage {
message: format!("Failed to close database: {error}"),
repair: Some("ee doctor".to_string()),
})?;
Ok(workspace_id)
}
fn normalize_workspace_path(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().unwrap_or_default().join(path)
}
}
fn resolve_workspace_id_with_fallback(
connection: &DbConnection,
workspace_path: &Path,
) -> Result<String, DomainError> {
let canonical = workspace_path
.canonicalize()
.unwrap_or_else(|_| workspace_path.to_path_buf());
crate::core::workspace::bound_workspace_id_or_hash(
connection,
&crate::core::workspace::stable_workspace_id(&canonical),
&[workspace_path, canonical.as_path()],
)
}
fn stable_workspace_id(path: &str) -> String {
crate::core::workspace::stable_workspace_id(Path::new(path))
}
fn generate_learning_record_id(prefix: &str) -> String {
format!("{}_{}", prefix, uuid::Uuid::now_v7().simple())
}
fn observation_signal_to_feedback(signal: LearningObservationSignal) -> &'static str {
match signal {
LearningObservationSignal::Positive => "positive",
LearningObservationSignal::Negative => "negative",
LearningObservationSignal::Neutral => "neutral",
LearningObservationSignal::Safety => "harmful",
}
}
fn outcome_status_to_feedback(status: ExperimentOutcomeStatus) -> &'static str {
match status {
ExperimentOutcomeStatus::Confirmed => "confirmation",
ExperimentOutcomeStatus::Rejected => "contradiction",
ExperimentOutcomeStatus::Inconclusive => "neutral",
ExperimentOutcomeStatus::Unsafe => "harmful",
}
}
fn downstream_effects_for_outcome(
outcome: &ExperimentOutcome,
dry_run: bool,
feedback: Option<&OutcomeRecordReport>,
) -> LearnOutcomeDownstreamEffects {
let mutation_mode = if dry_run {
"dry_run_projection"
} else if feedback.is_some_and(|report| report.status.as_str() == "already_recorded") {
"already_recorded_no_new_mutation"
} else {
"audited_feedback_recorded"
};
let source_id = feedback
.and_then(|report| report.source_id.clone())
.unwrap_or_else(|| outcome.outcome_id.clone());
LearnOutcomeDownstreamEffects {
schema: LEARN_DOWNSTREAM_EFFECTS_SCHEMA_V1,
mutation_mode: mutation_mode.to_string(),
economy_score: economy_score_effect_for_outcome(outcome),
procedure_drift: procedure_drift_effect_for_outcome(outcome),
tripwire_false_alarm: tripwire_false_alarm_effect_for_outcome(outcome),
situation_confidence: situation_confidence_effect_for_outcome(outcome),
audit: LearnOutcomeDownstreamAudit {
durable_feedback_recorded: !dry_run && feedback.is_some(),
source_type: "outcome_observed".to_string(),
source_id,
feedback_event_id: feedback.and_then(|report| report.event_id.clone()),
audit_id: feedback.and_then(|report| report.audit_id.clone()),
silent_mutation: false,
},
}
}
fn economy_score_effect_for_outcome(outcome: &ExperimentOutcome) -> LearnOutcomeEconomyScoreEffect {
let affected_artifact_ids = affected_artifact_ids(outcome);
let promoted_count = outcome.promoted_artifact_ids.len();
let demoted_count = outcome.demoted_artifact_ids.len();
let status_base = match outcome.status {
ExperimentOutcomeStatus::Confirmed => 0.10,
ExperimentOutcomeStatus::Rejected => -0.10,
ExperimentOutcomeStatus::Inconclusive => 0.0,
ExperimentOutcomeStatus::Unsafe => -0.25,
};
let artifact_delta = promoted_count as f64 * 0.02 - demoted_count as f64 * 0.02;
let utility_delta = rounded_metric(
(status_base + outcome.confidence_delta * 0.5 + artifact_delta).clamp(-1.0, 1.0),
);
let priority_multiplier = match outcome.status {
ExperimentOutcomeStatus::Confirmed => 1.05,
ExperimentOutcomeStatus::Rejected => 0.80,
ExperimentOutcomeStatus::Inconclusive => 1.0,
ExperimentOutcomeStatus::Unsafe => 0.50,
};
let scoring_note = match outcome.status {
ExperimentOutcomeStatus::Confirmed => {
"Confirmed experiment outcome raises utility for promoted evidence."
}
ExperimentOutcomeStatus::Rejected => {
"Rejected experiment outcome lowers utility and pushes demoted evidence toward review."
}
ExperimentOutcomeStatus::Inconclusive => {
"Inconclusive experiment outcome is retained without economy score movement."
}
ExperimentOutcomeStatus::Unsafe => {
"Unsafe experiment outcome sharply lowers utility pending manual review."
}
};
LearnOutcomeEconomyScoreEffect {
affected_artifact_ids,
promoted_count,
demoted_count,
utility_delta,
confidence_delta: rounded_metric(outcome.confidence_delta),
priority_delta: outcome.priority_delta,
priority_multiplier,
scoring_note: scoring_note.to_string(),
}
}
fn procedure_drift_effect_for_outcome(
outcome: &ExperimentOutcome,
) -> LearnOutcomeProcedureDriftEffect {
let procedure_artifact_ids = artifact_ids_for_kind(outcome, LearningTargetKind::Procedure);
let drift_signal = match outcome.status {
ExperimentOutcomeStatus::Confirmed => "validated_by_experiment",
ExperimentOutcomeStatus::Rejected => "contradicted_by_experiment",
ExperimentOutcomeStatus::Inconclusive => "needs_more_evidence",
ExperimentOutcomeStatus::Unsafe => "unsafe_drift",
};
let drift_score_delta = match outcome.status {
ExperimentOutcomeStatus::Confirmed => -0.10,
ExperimentOutcomeStatus::Rejected => 0.25,
ExperimentOutcomeStatus::Inconclusive => 0.05,
ExperimentOutcomeStatus::Unsafe => 0.50,
};
let requires_revalidation = matches!(
outcome.status,
ExperimentOutcomeStatus::Rejected
| ExperimentOutcomeStatus::Inconclusive
| ExperimentOutcomeStatus::Unsafe
) && !procedure_artifact_ids.is_empty();
let action = if procedure_artifact_ids.is_empty() {
"no_procedure_artifacts"
} else {
match outcome.status {
ExperimentOutcomeStatus::Confirmed => "promote_procedure_evidence",
ExperimentOutcomeStatus::Rejected => "revalidate_or_demote_procedure",
ExperimentOutcomeStatus::Inconclusive => "keep_procedure_pending",
ExperimentOutcomeStatus::Unsafe => "halt_procedure_promotion",
}
};
LearnOutcomeProcedureDriftEffect {
procedure_artifact_ids,
drift_signal: drift_signal.to_string(),
drift_score_delta: rounded_metric(drift_score_delta),
requires_revalidation,
action: action.to_string(),
}
}
fn tripwire_false_alarm_effect_for_outcome(
outcome: &ExperimentOutcome,
) -> LearnOutcomeTripwireFalseAlarmEffect {
let tripwire_artifact_ids = artifact_ids_for_kind(outcome, LearningTargetKind::Tripwire);
let demoted_tripwire_count =
artifact_ids_for_kind_from(&outcome.demoted_artifact_ids, LearningTargetKind::Tripwire)
.len();
let promoted_tripwire_count =
artifact_ids_for_kind_from(&outcome.promoted_artifact_ids, LearningTargetKind::Tripwire)
.len();
let false_alarm_cost_delta = if matches!(
outcome.status,
ExperimentOutcomeStatus::Rejected | ExperimentOutcomeStatus::Unsafe
) {
demoted_tripwire_count as u32
} else {
0
};
let confidence_delta = if false_alarm_cost_delta > 0 {
-0.12 * f64::from(false_alarm_cost_delta)
} else if outcome.status == ExperimentOutcomeStatus::Confirmed && promoted_tripwire_count > 0 {
0.08 * promoted_tripwire_count as f64
} else {
0.0
};
let (action, scoring_note) = if false_alarm_cost_delta > 0 {
(
"increase_false_alarm_cost",
"Closed outcome contradicted a demoted tripwire; increase false-alarm cost without deleting evidence.",
)
} else if outcome.status == ExperimentOutcomeStatus::Confirmed && promoted_tripwire_count > 0 {
(
"confirm_tripwire",
"Closed outcome confirmed promoted tripwire evidence.",
)
} else {
(
"retain_tripwire_audit",
"Closed outcome does not move tripwire false-alarm cost.",
)
};
LearnOutcomeTripwireFalseAlarmEffect {
tripwire_artifact_ids,
false_alarm_cost_delta,
confidence_delta: rounded_metric(confidence_delta),
action: action.to_string(),
scoring_note: scoring_note.to_string(),
}
}
fn situation_confidence_effect_for_outcome(
outcome: &ExperimentOutcome,
) -> LearnOutcomeSituationConfidenceEffect {
let situation_artifact_ids = artifact_ids_for_kind(outcome, LearningTargetKind::Situation);
let confidence_delta = rounded_metric(outcome.confidence_delta);
let confidence_direction = if confidence_delta > 0.0 {
"increase"
} else if confidence_delta < 0.0 {
"decrease"
} else {
"unchanged"
};
let action = if situation_artifact_ids.is_empty() {
"no_situation_artifacts"
} else {
match confidence_direction {
"increase" => "increase_situation_confidence",
"decrease" => "decrease_situation_confidence",
_ => "retain_situation_confidence",
}
};
LearnOutcomeSituationConfidenceEffect {
situation_artifact_ids,
confidence_delta,
confidence_direction: confidence_direction.to_string(),
action: action.to_string(),
}
}
fn affected_artifact_ids(outcome: &ExperimentOutcome) -> Vec<String> {
let mut ids = outcome.promoted_artifact_ids.clone();
ids.extend(outcome.demoted_artifact_ids.iter().cloned());
ids.sort();
ids.dedup();
ids
}
fn artifact_ids_for_kind(outcome: &ExperimentOutcome, kind: LearningTargetKind) -> Vec<String> {
artifact_ids_for_kind_from(&affected_artifact_ids(outcome), kind)
}
fn artifact_ids_for_kind_from(ids: &[String], kind: LearningTargetKind) -> Vec<String> {
let mut matching = ids
.iter()
.filter(|id| infer_learning_target_kind(id) == kind)
.cloned()
.collect::<Vec<_>>();
matching.sort();
matching.dedup();
matching
}
fn infer_learning_target_kind(artifact_id: &str) -> LearningTargetKind {
let artifact = artifact_id.to_ascii_lowercase();
if artifact.starts_with("proc_")
|| artifact.starts_with("procedure_")
|| artifact.contains("procedure")
{
LearningTargetKind::Procedure
} else if artifact.starts_with("tw_")
|| artifact.starts_with("tripwire_")
|| artifact.contains("tripwire")
{
LearningTargetKind::Tripwire
} else if artifact.starts_with("sit_")
|| artifact.starts_with("situation_")
|| artifact.contains("situation")
{
LearningTargetKind::Situation
} else if artifact.starts_with("econ_")
|| artifact.starts_with("economy_")
|| artifact.contains("economy")
|| artifact.contains("budget")
{
LearningTargetKind::Economy
} else if artifact.starts_with("decision_") || artifact.contains("decision") {
LearningTargetKind::Decision
} else {
LearningTargetKind::Memory
}
}
fn require_text(field: &str, raw: &str, repair: &str) -> Result<String, DomainError> {
let value = raw.trim();
if value.is_empty() {
Err(DomainError::Usage {
message: format!("{field} must not be empty"),
repair: Some(repair.to_string()),
})
} else {
Ok(value.to_string())
}
}
fn normalize_optional_text(
field: &str,
raw: Option<&str>,
repair: &str,
) -> Result<Option<String>, DomainError> {
raw.map(|value| require_text(field, value, repair))
.transpose()
}
fn normalize_text_list(
field: &str,
raw: &[String],
repair: &str,
) -> Result<Vec<String>, DomainError> {
let mut values = raw
.iter()
.map(|value| require_text(field, value, repair))
.collect::<Result<Vec<_>, _>>()?;
values.sort();
values.dedup();
Ok(values)
}
fn validate_optional_metric(value: Option<f64>) -> Result<Option<f64>, DomainError> {
value
.map(|metric| validate_metric(metric, "measurement value"))
.transpose()
}
fn validate_metric(value: f64, field: &str) -> Result<f64, DomainError> {
if value.is_finite() {
Ok(value)
} else {
Err(DomainError::Usage {
message: format!("{field} must be finite"),
repair: Some("Use a finite numeric value.".to_string()),
})
}
}
fn validate_metric_delta(value: f64, field: &str) -> Result<f64, DomainError> {
let value = validate_metric(value, field)?;
if (-1.0..=1.0).contains(&value) {
Ok(value)
} else {
Err(DomainError::Usage {
message: format!("{field} must be between -1.0 and 1.0"),
repair: Some("Use --confidence-delta 0.0".to_string()),
})
}
}
#[derive(Clone, Debug)]
pub struct LearnExperimentProposeOptions {
pub workspace: PathBuf,
pub limit: u32,
pub topic: Option<String>,
pub min_expected_value: f64,
pub max_attention_tokens: u32,
pub max_runtime_seconds: u32,
pub safety_boundary: ExperimentSafetyBoundary,
}
#[derive(Clone, Debug)]
pub struct LearnExperimentRunOptions {
pub workspace: PathBuf,
pub experiment_id: String,
pub max_attention_tokens: u32,
pub max_runtime_seconds: u32,
pub dry_run: bool,
}
impl Default for LearnExperimentProposeOptions {
fn default() -> Self {
Self {
workspace: PathBuf::new(),
limit: 3,
topic: None,
min_expected_value: 0.0,
max_attention_tokens: 1_200,
max_runtime_seconds: 300,
safety_boundary: ExperimentSafetyBoundary::DryRunOnly,
}
}
}
impl Default for LearnExperimentRunOptions {
fn default() -> Self {
Self {
workspace: PathBuf::new(),
experiment_id: String::new(),
max_attention_tokens: 1_200,
max_runtime_seconds: 300,
dry_run: true,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentBudget {
pub attention_tokens: u32,
pub max_runtime_seconds: u32,
pub dry_run_required: bool,
pub budget_class: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentSafetyPlan {
pub boundary: String,
pub dry_run_first: bool,
pub mutation_allowed: bool,
pub review_required: bool,
pub stop_conditions: Vec<String>,
pub denied_reasons: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentDecisionImpact {
pub decision_id: String,
pub target_artifact_ids: Vec<String>,
pub current_decision: String,
pub possible_change: String,
pub impact_score: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentProposal {
pub experiment_id: String,
pub question_id: String,
pub title: String,
pub hypothesis: String,
pub status: String,
pub topic: String,
pub expected_value: f64,
pub uncertainty_reduction: f64,
pub confidence: f64,
pub budget: ExperimentBudget,
pub safety: ExperimentSafetyPlan,
pub decision_impact: ExperimentDecisionImpact,
pub evidence_ids: Vec<String>,
pub next_command: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LearnExperimentProposalReport {
pub schema: String,
pub proposals: Vec<ExperimentProposal>,
pub total_candidates: u32,
pub returned: u32,
pub min_expected_value: f64,
pub max_attention_tokens: u32,
pub max_runtime_seconds: u32,
pub generated_at: String,
}
impl LearnExperimentProposalReport {
#[must_use]
pub fn to_json(&self) -> String {
let mut public = self.clone();
for proposal in &mut public.proposals {
proposal.evidence_ids = redact_learning_public_refs(&proposal.evidence_ids);
proposal.decision_impact.target_artifact_ids =
redact_learning_public_refs(&proposal.decision_impact.target_artifact_ids);
}
crate::core::serialize_or_error(&public)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentRunBudget {
pub requested_attention_tokens: u32,
pub requested_runtime_seconds: u32,
pub planned_attention_tokens: u32,
pub planned_runtime_seconds: u32,
pub shadow_budget_delta_tokens: i32,
pub budget_class: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentRunStep {
pub order: u32,
pub name: String,
pub action: String,
pub expected_signal: String,
pub writes_storage: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentRunObservationPreview {
pub signal: String,
pub measurement_name: String,
pub measurement_value: Option<f64>,
pub evidence_ids: Vec<String>,
pub note: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ExperimentRunOutcomePreview {
pub status: String,
pub decision_impact: String,
pub confidence_delta: f64,
pub priority_delta: i32,
pub promoted_artifact_ids: Vec<String>,
pub demoted_artifact_ids: Vec<String>,
pub safety_notes: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LearnExperimentRunReport {
pub schema: String,
pub status: String,
pub dry_run: bool,
pub experiment_id: String,
pub experiment_kind: String,
pub title: String,
pub hypothesis: String,
pub budget: ExperimentRunBudget,
pub safety: ExperimentSafetyPlan,
pub steps: Vec<ExperimentRunStep>,
pub observations: Vec<ExperimentRunObservationPreview>,
pub outcome_preview: ExperimentRunOutcomePreview,
pub next_actions: Vec<String>,
pub generated_at: String,
}
impl LearnExperimentRunReport {
#[must_use]
pub fn data_json(&self) -> serde_json::Value {
serde_json::json!({
"schema": self.schema,
"success": true,
"status": self.status,
"dryRun": self.dry_run,
"experimentId": self.experiment_id,
"experimentKind": self.experiment_kind,
"title": self.title,
"hypothesis": self.hypothesis,
"budget": self.budget,
"safety": self.safety,
"steps": self.steps,
"observations": self
.observations
.iter()
.map(experiment_run_observation_preview_public_json)
.collect::<Vec<_>>(),
"outcomePreview": experiment_run_outcome_preview_public_json(&self.outcome_preview),
"nextActions": self.next_actions,
"generatedAt": self.generated_at,
})
}
#[must_use]
pub fn human_summary(&self) -> String {
let mut output = String::new();
output.push_str("Learning Experiment Run [DRY RUN]\n\n");
output.push_str(&format!("Experiment: {}\n", self.experiment_id));
output.push_str(&format!("Kind: {}\n", self.experiment_kind));
output.push_str(&format!("Status: {}\n", self.status));
output.push_str(&format!(
"Budget: {} tokens, {}s runtime ({})\n",
self.budget.planned_attention_tokens,
self.budget.planned_runtime_seconds,
self.budget.budget_class
));
output.push_str("\nSteps:\n");
for step in &self.steps {
output.push_str(&format!(
" {}. {} -> {}\n",
step.order, step.name, step.expected_signal
));
}
output.push_str("\nNext:\n");
for action in &self.next_actions {
output.push_str(&format!(" {action}\n"));
}
output
}
#[must_use]
pub fn toon_summary(&self) -> String {
format!(
"LEARN_EXPERIMENT_RUN|{}|{}|steps={}|observations={}|dry_run={}",
self.experiment_id,
self.experiment_kind,
self.steps.len(),
self.observations.len(),
self.dry_run
)
}
}
pub fn propose_experiments(
options: &LearnExperimentProposeOptions,
) -> Result<LearnExperimentProposalReport, DomainError> {
let snapshot = load_learning_snapshot(&options.workspace)?;
let clusters = build_learning_clusters(
&snapshot,
options.topic.as_deref(),
&snapshot.feedback_events,
);
let database_path = learning_database_path(None, &options.workspace);
let connection =
DbConnection::open_file(&database_path).map_err(|error| DomainError::Storage {
message: format!("Failed to open database: {error}"),
repair: Some("ee doctor".to_string()),
})?;
let mut durable_proposals = Vec::new();
for cluster in clusters
.iter()
.filter(|cluster| cluster.is_non_trivial())
.filter(|cluster| cluster.expected_value() >= options.min_expected_value)
{
let Some(target_memory_id) = cluster.target_memory_id() else {
continue;
};
let candidate_id = cluster.curation_candidate_id();
let proposed_content = cluster.proposed_rule_content();
let source_ids = cluster.sample_ids_vec();
let already_exists = connection
.get_curation_candidate(&snapshot.workspace_id, &candidate_id)
.map_err(|error| DomainError::Storage {
message: format!("Failed to check learning curation candidate: {error}"),
repair: Some("ee curate candidates --json".to_string()),
})?
.is_some();
if !already_exists {
let uses_peer_evidence = source_ids.iter().any(|id| is_peer_evidence_source_ref(id));
let source_id = if uses_peer_evidence {
source_ids.join(",")
} else {
snapshot.feedback_events.iter()
.filter(|event| cluster.sample_ids.contains(&event.id))
.filter(|event| crate::policy::validate_trust_promotion_evidence(
"agent_validated", "feedback_event", &event.id,
).is_ok())
.map(|event| event.id.as_str()).min()
.ok_or_else(|| DomainError::Usage {
message: "Learning evidence changed or contains no canonical feedback event; refresh the proposal.".to_owned(),
repair: Some("ee learn experiment propose --json".to_owned()),
})?.to_owned()
};
let source_refs = cluster
.memory_ids
.iter()
.filter_map(|id| snapshot.memories.get(id))
.map(|memory| {
crate::curate::DerivationSourceRef::new(
crate::curate::DerivationSourceKind::Memory,
&memory.id,
format!(
"blake3:{}",
blake3::hash(memory.content.as_bytes()).to_hex()
),
)
})
.collect::<Vec<_>>();
let source_refs_json = crate::curate::canonical_derivation_source_refs_json(
&source_refs,
)
.map_err(|error| DomainError::Storage {
message: format!("Failed to preserve learning source references: {error}"),
repair: Some("ee learn experiment propose --json".to_owned()),
})?;
let mut reason = cluster.proposal_reason();
if uses_peer_evidence {
reason.push_str(
" Peer-origin evidence contributed to this proposal; the candidate is capped at agent_assertion until local review or outcome feedback validates it.",
);
}
let input = CreateCurationCandidateInput {
workspace_id: snapshot.workspace_id.clone(),
candidate_type: "rule".to_string(),
target_memory_id: Some(target_memory_id.clone()),
proposed_content: Some(proposed_content),
proposed_confidence: Some(cluster.proposed_confidence()),
proposed_trust_class: Some(
if uses_peer_evidence {
"agent_assertion"
} else {
"agent_validated"
}
.to_string(),
),
source_type: if uses_peer_evidence {
"agent_inference".to_string()
} else {
"feedback_event".to_string()
},
source_id: Some(source_id),
reason,
confidence: cluster.proposed_confidence(),
status: Some("pending".to_string()),
created_at: Some(stable_learning_generated_at()),
ttl_expires_at: None,
derivation_source_refs_json: None,
derivation_metadata_json: None,
};
let source_refs: serde_json::Value =
serde_json::from_str(&source_refs_json).map_err(|error| DomainError::Storage {
message: format!("Failed to encode learning source references: {error}"),
repair: Some("ee doctor --json".to_owned()),
})?;
let details = serde_json::json!({
"schema": "ee.audit.curation_candidate_create.v1",
"proposalSource": "learn.experiment.propose",
"workspaceId": input.workspace_id,
"candidateId": candidate_id,
"candidateType": input.candidate_type,
"sourceType": input.source_type,
"sourceId": input.source_id,
"targetMemoryId": input.target_memory_id,
"proposedContentHash": blake3::hash(input.proposed_content.as_deref().unwrap_or_default().as_bytes()).to_hex().to_string(),
"sourceRefs": source_refs,
"learningEvidenceIds": cluster.sample_ids,
});
connection
.with_transaction(|| {
if connection
.get_curation_candidate(&snapshot.workspace_id, &candidate_id)?
.is_some()
{
return Ok(());
}
connection.insert_curation_candidate(&candidate_id, &input)?;
connection.insert_audit(
&crate::db::generate_audit_id(),
&CreateAuditInput {
workspace_id: Some(snapshot.workspace_id.clone()),
actor: Some("learn.experiment.propose".to_owned()),
action: audit_actions::CURATION_CANDIDATE_CREATE.to_owned(),
target_type: Some("curation_candidate".to_owned()),
target_id: Some(candidate_id.clone()),
details: Some(details.to_string()),
},
)?;
Ok(())
})
.map_err(|error| DomainError::Storage {
message: format!(
"Failed to persist learning candidate and evidence audit: {error}"
),
repair: Some("ee curate candidates --json".to_string()),
})?;
}
durable_proposals.push(cluster.experiment_proposal(
options.max_attention_tokens,
options.max_runtime_seconds,
options.safety_boundary,
));
}
connection.close().map_err(|error| DomainError::Storage {
message: format!("Failed to close database: {error}"),
repair: Some("ee doctor".to_string()),
})?;
durable_proposals.sort_by(|left, right| {
right
.expected_value
.total_cmp(&left.expected_value)
.then_with(|| left.topic.cmp(&right.topic))
.then_with(|| left.experiment_id.cmp(&right.experiment_id))
});
let total_candidates = durable_proposals.len() as u32;
durable_proposals.truncate(options.limit as usize);
let returned = durable_proposals.len() as u32;
Ok(LearnExperimentProposalReport {
schema: LEARN_EXPERIMENT_PROPOSAL_SCHEMA_V1.to_string(),
proposals: durable_proposals,
total_candidates,
returned,
min_expected_value: rounded_metric(options.min_expected_value),
max_attention_tokens: options.max_attention_tokens,
max_runtime_seconds: options.max_runtime_seconds,
generated_at: stable_learning_generated_at(),
})
}
pub fn run_experiment(
options: &LearnExperimentRunOptions,
) -> Result<LearnExperimentRunReport, DomainError> {
if !options.dry_run {
return Err(DomainError::PolicyDenied {
message: "Learning experiment execution requires --dry-run; durable outcome changes must go through learn observe and learn close.".to_string(),
repair: Some(
"Use ee learn experiment run --id <experiment-id> --dry-run --json".to_string(),
),
});
}
let snapshot = load_learning_snapshot(&options.workspace)?;
let proposal =
registered_experiment_proposal(&snapshot, &options.experiment_id).ok_or_else(|| {
DomainError::NotFound {
resource: "learning experiment".to_string(),
id: options.experiment_id.clone(),
repair: Some(
"Run ee learn experiment propose --json to register experiment definitions."
.to_string(),
),
}
})?;
Ok(run_report_from_registered_proposal(options, &proposal))
}
fn registered_experiment_proposal(
snapshot: &LearningSnapshot,
experiment_id: &str,
) -> Option<ExperimentProposal> {
let mut clusters = build_learning_clusters(snapshot, None, &snapshot.feedback_events);
clusters.sort_by(|left, right| {
left.topic
.cmp(&right.topic)
.then_with(|| left.question_id().cmp(&right.question_id()))
});
clusters
.into_iter()
.filter(LearningCluster::is_non_trivial)
.find_map(|cluster| {
if cluster.experiment_id() != experiment_id {
return None;
}
let candidate_id = cluster.curation_candidate_id();
let registered = snapshot
.curation_candidates
.iter()
.any(|candidate| candidate.id == candidate_id && candidate.status == "pending");
registered.then(|| {
cluster.experiment_proposal(1_200, 300, ExperimentSafetyBoundary::DryRunOnly)
})
})
}
fn run_report_from_registered_proposal(
options: &LearnExperimentRunOptions,
proposal: &ExperimentProposal,
) -> LearnExperimentRunReport {
let planned_attention_tokens = options
.max_attention_tokens
.min(proposal.budget.attention_tokens);
let planned_runtime_seconds = options
.max_runtime_seconds
.min(proposal.budget.max_runtime_seconds);
let shadow_budget_delta_tokens = i32::try_from(planned_attention_tokens).unwrap_or(i32::MAX)
- i32::try_from(proposal.budget.attention_tokens).unwrap_or(i32::MAX);
let evidence_ids = proposal.evidence_ids.clone();
LearnExperimentRunReport {
schema: LEARN_EXPERIMENT_RUN_SCHEMA_V1.to_string(),
status: "dry_run_ready".to_string(),
dry_run: true,
experiment_id: proposal.experiment_id.clone(),
experiment_kind: "active_learning_replay".to_string(),
title: proposal.title.clone(),
hypothesis: proposal.hypothesis.clone(),
budget: ExperimentRunBudget {
requested_attention_tokens: options.max_attention_tokens,
requested_runtime_seconds: options.max_runtime_seconds,
planned_attention_tokens,
planned_runtime_seconds,
shadow_budget_delta_tokens,
budget_class: budget_class(planned_attention_tokens, planned_runtime_seconds).to_string(),
},
safety: proposal.safety.clone(),
steps: vec![
ExperimentRunStep {
order: 1,
name: "load_registered_definition".to_string(),
action: format!(
"Read persisted curation-backed experiment definition {}.",
proposal.experiment_id
),
expected_signal: "registered_definition_loaded".to_string(),
writes_storage: false,
},
ExperimentRunStep {
order: 2,
name: "replay_evidence_cluster".to_string(),
action: format!(
"Review {} evidence pointer(s) for topic {}.",
evidence_ids.len(),
proposal.topic
),
expected_signal: "evidence_replay_complete".to_string(),
writes_storage: false,
},
ExperimentRunStep {
order: 3,
name: "preview_learning_outcome".to_string(),
action: "Prepare learn observe and learn close payload previews without mutating storage."
.to_string(),
expected_signal: "outcome_preview_ready".to_string(),
writes_storage: false,
},
],
observations: vec![ExperimentRunObservationPreview {
signal: "neutral".to_string(),
measurement_name: "expected_value".to_string(),
measurement_value: Some(proposal.expected_value),
evidence_ids: evidence_ids.clone(),
note: format!(
"Dry-run replay for {} is ready for human review before learn observe records evidence.",
proposal.topic
),
}],
outcome_preview: ExperimentRunOutcomePreview {
status: "pending_review".to_string(),
decision_impact: proposal.decision_impact.possible_change.clone(),
confidence_delta: rounded_metric(proposal.uncertainty_reduction * 0.1),
priority_delta: if proposal.expected_value >= 0.5 { 1 } else { 0 },
promoted_artifact_ids: Vec::new(),
demoted_artifact_ids: Vec::new(),
safety_notes: vec![
"Dry-run report does not write learning observations or curation decisions."
.to_string(),
"Use learn observe and learn close to persist reviewed results.".to_string(),
],
},
next_actions: vec![
format!(
"ee learn observe {} --signal neutral --measurement-name expected_value --json",
proposal.experiment_id
),
format!(
"ee learn close {} --status inconclusive --dry-run --json",
proposal.experiment_id
),
],
generated_at: stable_learning_generated_at(),
}
}
fn rounded_metric(value: f64) -> f64 {
if value.is_finite() {
(value * 1000.0).round() / 1000.0
} else {
0.0
}
}
fn stable_learning_generated_at() -> String {
crate::obs::now_rfc3339_nanos()
}
#[derive(Clone, Debug)]
struct LearningSnapshot {
workspace_id: String,
memories: BTreeMap<String, StoredMemory>,
memory_tags: BTreeMap<String, Vec<String>>,
feedback_events: Vec<StoredFeedbackEvent>,
audit_entries: Vec<StoredAuditEntry>,
learning_observations: Vec<StoredLearningObservation>,
curation_candidates: Vec<StoredCurationCandidate>,
}
struct LearningObservationLedgerInput<'a> {
observation_kind: &'a str,
source_type: &'a str,
source_id: Option<String>,
target_type: &'a str,
target_id: &'a str,
topic: Option<String>,
signal: &'a str,
evidence_json: Option<String>,
observed_at: &'a str,
}
#[derive(Clone, Debug)]
struct LearningCluster {
topic: String,
positive_count: u32,
negative_count: u32,
neutral_count: u32,
decay_count: u32,
positive_weight: f64,
negative_weight: f64,
neutral_weight: f64,
decay_weight: f64,
memory_ids: BTreeSet<String>,
sample_ids: BTreeSet<String>,
source_types: BTreeSet<String>,
content_previews: BTreeSet<String>,
last_seen_at: String,
}
impl LearningCluster {
fn new(topic: String) -> Self {
Self {
topic,
positive_count: 0,
negative_count: 0,
neutral_count: 0,
decay_count: 0,
positive_weight: 0.0,
negative_weight: 0.0,
neutral_weight: 0.0,
decay_weight: 0.0,
memory_ids: BTreeSet::new(),
sample_ids: BTreeSet::new(),
source_types: BTreeSet::new(),
content_previews: BTreeSet::new(),
last_seen_at: String::new(),
}
}
fn record(
&mut self,
event: &StoredFeedbackEvent,
evidence_ids: BTreeSet<String>,
memory_ids: BTreeSet<String>,
previews: BTreeSet<String>,
) {
match signal_bucket(&event.signal) {
SignalBucket::Positive => {
self.positive_count = self.positive_count.saturating_add(1);
self.positive_weight += f64::from(event.weight);
}
SignalBucket::Negative => {
self.negative_count = self.negative_count.saturating_add(1);
self.negative_weight += f64::from(event.weight);
}
SignalBucket::Decay => {
self.decay_count = self.decay_count.saturating_add(1);
self.decay_weight += f64::from(event.weight);
}
SignalBucket::Neutral => {
self.neutral_count = self.neutral_count.saturating_add(1);
self.neutral_weight += f64::from(event.weight);
}
}
self.sample_ids.insert(event.id.clone());
self.sample_ids.insert(event.target_id.clone());
if let Some(source_id) = &event.source_id {
self.sample_ids.insert(source_id.clone());
}
self.sample_ids.extend(evidence_ids);
self.memory_ids.extend(memory_ids);
self.source_types.insert(event.source_type.clone());
self.content_previews.extend(previews);
if event.created_at > self.last_seen_at {
self.last_seen_at = event.created_at.clone();
}
}
fn total_count(&self) -> u32 {
self.positive_count + self.negative_count + self.neutral_count + self.decay_count
}
fn confidence(&self) -> f64 {
let total =
self.positive_weight + self.negative_weight + self.neutral_weight + self.decay_weight;
if total <= f64::EPSILON {
return 0.0;
}
rounded_metric(
((self.positive_weight + self.neutral_weight * 0.5)
/ (total + self.decay_weight * 0.5))
.clamp(0.0, 1.0),
)
}
fn uncertainty(&self) -> f64 {
let positive = self.positive_weight.max(0.0);
let negative = self.negative_weight.max(0.0);
let neutral = (self.neutral_weight + self.decay_weight).max(0.0);
let total = positive + negative + neutral;
let entropy = if total <= f64::EPSILON {
0.0
} else {
let mut entropy = 0.0;
for weight in [positive, negative, neutral] {
if weight > f64::EPSILON {
let probability = weight / total;
entropy -= probability * probability.log2();
}
}
entropy / 3.0_f64.log2()
};
let scarcity = if self.total_count() >= 4 {
0.0
} else {
(4.0 - f64::from(self.total_count())) / 4.0
};
rounded_metric(entropy.max(scarcity).clamp(0.0, 1.0))
}
fn priority(&self) -> u8 {
let evidence_bonus = f64::from(self.total_count().min(20)) * 1.5;
let contradiction_bonus = if self.positive_count > 0 && self.negative_count > 0 {
15.0
} else {
0.0
};
let priority = self.uncertainty() * 65.0 + evidence_bonus + contradiction_bonus;
priority.round().clamp(1.0, 100.0) as u8
}
fn status(&self) -> &'static str {
if self.total_count() >= 8
&& self.negative_count == 0
&& self.decay_count == 0
&& self.uncertainty() < 0.25
{
"resolved"
} else {
"open"
}
}
fn agenda_item(&self) -> AgendaItem {
AgendaItem {
id: self.question_id(),
topic: self.topic.clone(),
gap_description: self.gap_description(),
priority: self.priority(),
uncertainty: self.uncertainty(),
source: self.source(),
sample_ids: self.sample_ids_vec(),
status: self.status().to_string(),
created_at: self.last_seen_at.clone(),
}
}
fn uncertainty_item(&self) -> UncertaintyItem {
let (content, content_truncated) = self.content_preview_with_flag();
UncertaintyItem {
memory_id: self
.target_memory_id()
.unwrap_or_else(|| self.question_id()),
content,
content_truncated,
kind: self.topic.clone(),
uncertainty: self.uncertainty(),
confidence: self.confidence(),
retrieval_count: self.total_count(),
last_accessed: Some(self.last_seen_at.clone()),
}
}
fn experiment_proposal(
&self,
max_attention_tokens: u32,
max_runtime_seconds: u32,
safety_boundary: ExperimentSafetyBoundary,
) -> ExperimentProposal {
let evidence_ids = self.sample_ids_vec();
let target_memory_id = self
.target_memory_id()
.unwrap_or_else(|| self.question_id());
let experiment_id = self.experiment_id();
ExperimentProposal {
experiment_id: experiment_id.clone(),
question_id: self.question_id(),
title: format!("Validate {} learning cluster", self.topic),
hypothesis: self.proposal_hypothesis(),
status: "proposed".to_string(),
topic: self.topic.clone(),
expected_value: self.expected_value(),
uncertainty_reduction: rounded_metric((self.uncertainty() * 0.45 + 0.15).min(1.0)),
confidence: self.confidence(),
budget: ExperimentBudget {
attention_tokens: max_attention_tokens,
max_runtime_seconds,
dry_run_required: true,
budget_class: budget_class(max_attention_tokens, max_runtime_seconds).to_string(),
},
safety: safety_plan(safety_boundary),
decision_impact: ExperimentDecisionImpact {
decision_id: format!(
"decision_{}",
stable_suffix("learn_decision", &self.topic, 20)
),
target_artifact_ids: vec![target_memory_id],
current_decision: self.current_decision(),
possible_change: self.possible_change(),
impact_score: rounded_metric((self.expected_value() + self.uncertainty()) / 2.0),
},
evidence_ids,
next_command: format!("ee learn experiment run --dry-run --id {experiment_id} --json"),
}
}
fn is_non_trivial(&self) -> bool {
self.total_count() >= 2 && self.sample_ids.len() >= 2 && !self.memory_ids.is_empty()
}
fn question_id(&self) -> String {
format!("gap_{}", stable_suffix("learn_question", &self.topic, 20))
}
fn experiment_id(&self) -> String {
format!("exp_{}", stable_suffix("learn_experiment", &self.topic, 24))
}
fn curation_candidate_id(&self) -> String {
format!(
"curate_{}",
stable_suffix("learn_candidate", &self.topic, 26)
)
}
fn target_memory_id(&self) -> Option<String> {
self.memory_ids.iter().next().cloned()
}
fn sample_ids_vec(&self) -> Vec<String> {
let mut selected = BTreeSet::new();
for id in self
.sample_ids
.iter()
.filter(|id| is_peer_evidence_source_ref(id))
{
if selected.len() >= 12 {
break;
}
selected.insert(id.clone());
}
for id in self
.sample_ids
.iter()
.filter(|id| !is_peer_evidence_source_ref(id))
{
if selected.len() >= 12 {
break;
}
selected.insert(id.clone());
}
selected.into_iter().collect()
}
fn proposed_confidence(&self) -> f32 {
rounded_metric((self.confidence() * 0.75 + self.expected_value() * 0.25).clamp(0.05, 0.95))
as f32
}
fn expected_value(&self) -> f64 {
let evidence_strength = (f64::from(self.total_count()).ln_1p() / 4.0).min(0.35);
let contradiction_value = if self.positive_count > 0 && self.negative_count > 0 {
0.15
} else {
0.0
};
rounded_metric(
(self.uncertainty() * 0.35
+ self.confidence() * 0.20
+ evidence_strength
+ contradiction_value)
.clamp(0.0, 1.0),
)
}
fn proposed_rule_content(&self) -> String {
if self.positive_count >= self.negative_count {
format!(
"For {}, prefer the pattern supported by {} positive outcome(s) and {} total observation(s): {}",
self.topic,
self.positive_count,
self.total_count(),
self.content_preview()
)
} else {
format!(
"For {}, avoid or revalidate the pattern contradicted by {} negative outcome(s): {}",
self.topic,
self.negative_count,
self.content_preview()
)
}
}
fn proposal_reason(&self) -> String {
format!(
"Learning cluster `{}` has {} observation(s), uncertainty {:.3}, confidence {:.3}, and {} evidence pointer(s).",
self.topic,
self.total_count(),
self.uncertainty(),
self.confidence(),
self.sample_ids.len()
)
}
fn gap_description(&self) -> String {
if self.positive_count > 0 && self.negative_count > 0 {
format!(
"{} has contradictory outcome evidence; replay or review before promoting a procedural rule.",
self.topic
)
} else if self.total_count() < 3 {
format!(
"{} has only {} outcome observation(s); gather more evidence before promotion.",
self.topic,
self.total_count()
)
} else if self.negative_count > 0 || self.decay_count > 0 {
format!(
"{} has harmful, stale, or contradictory feedback that needs procedural review.",
self.topic
)
} else {
format!(
"{} has repeated supportive outcomes and is ready for a candidate procedural rule.",
self.topic
)
}
}
fn content_preview(&self) -> String {
self.content_previews
.iter()
.next()
.cloned()
.unwrap_or_else(|| format!("Outcome observations for {}.", self.topic))
}
fn content_preview_with_flag(&self) -> (String, bool) {
let preview = self.content_preview();
(preview, self.content_previews.len() > 1)
}
fn source(&self) -> String {
if self.source_types.is_empty() {
"feedback_event".to_string()
} else {
self.source_types
.iter()
.cloned()
.collect::<Vec<_>>()
.join(",")
}
}
fn proposal_hypothesis(&self) -> String {
if self.positive_count > 0 && self.negative_count > 0 {
format!(
"A dry-run comparison can separate valid {} guidance from contradicted cases.",
self.topic
)
} else {
format!(
"The repeated {} outcomes can be consolidated into a durable procedural rule.",
self.topic
)
}
}
fn current_decision(&self) -> String {
format!(
"Keep {} guidance at confidence {:.3} until the evidence cluster is reviewed.",
self.topic,
self.confidence()
)
}
fn possible_change(&self) -> String {
if self.negative_count > self.positive_count {
"Demote or quarantine the candidate rule if replay confirms harmful outcomes."
.to_string()
} else {
"Promote a candidate procedural rule through ee curate candidates.".to_string()
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SignalBucket {
Positive,
Negative,
Neutral,
Decay,
}
fn load_learning_clusters(
workspace: &Path,
topic_filter: Option<&str>,
) -> Result<Vec<LearningCluster>, DomainError> {
let snapshot = load_learning_snapshot(workspace)?;
let events = snapshot.feedback_events.clone();
Ok(build_learning_clusters(&snapshot, topic_filter, &events))
}
fn load_learning_snapshot(workspace: &Path) -> Result<LearningSnapshot, DomainError> {
let database_path = learning_database_path(None, workspace);
if !database_path.exists() {
return Err(crate::core::storeless_workspace_error(&database_path));
}
let connection =
DbConnection::open_file(&database_path).map_err(|error| DomainError::Storage {
message: format!("Failed to open database: {error}"),
repair: Some("ee doctor".to_string()),
})?;
let normalized_workspace = normalize_workspace_path(workspace);
let workspace_id = resolve_workspace_id_with_fallback(&connection, &normalized_workspace)?;
let memory_rows = connection
.list_memories(&workspace_id, None, false)
.map_err(|error| DomainError::Storage {
message: format!("Failed to list learning memories: {error}"),
repair: Some("ee remember --workspace . --json".to_string()),
})?;
let memories = memory_rows
.into_iter()
.map(|memory| (memory.id.clone(), memory))
.collect::<BTreeMap<_, _>>();
let memory_ids = memories.keys().map(String::as_str).collect::<Vec<_>>();
let memory_tags = connection
.get_memory_tags_batch(&memory_ids)
.map_err(|error| DomainError::Storage {
message: format!("Failed to load memory tags for learning: {error}"),
repair: Some("ee doctor".to_string()),
})?;
let feedback_events = connection
.list_feedback_events(&workspace_id)
.map_err(|error| DomainError::Storage {
message: format!("Failed to list learning feedback events: {error}"),
repair: Some("ee outcome list --json".to_string()),
})?;
let audit_entries = connection
.list_audit_entries(Some(&workspace_id), None)
.map_err(|error| DomainError::Storage {
message: format!("Failed to list learning audit entries: {error}"),
repair: Some("ee audit timeline --json".to_string()),
})?;
let learning_observations = connection
.list_learning_observations(&workspace_id, None)
.map_err(|error| DomainError::Storage {
message: format!("Failed to list learning observations: {error}"),
repair: Some("ee learn observe --help".to_string()),
})?;
let curation_candidates = connection
.list_curation_candidates(&workspace_id, None, None, None)
.map_err(|error| DomainError::Storage {
message: format!("Failed to list curation candidates: {error}"),
repair: Some("ee curate candidates --json".to_string()),
})?;
connection.close().map_err(|error| DomainError::Storage {
message: format!("Failed to close database: {error}"),
repair: Some("ee doctor".to_string()),
})?;
Ok(LearningSnapshot {
workspace_id,
memories,
memory_tags,
feedback_events,
audit_entries,
learning_observations,
curation_candidates,
})
}
fn persist_learning_observation(
database_path: &Path,
workspace_id: &str,
input: &LearningObservationLedgerInput<'_>,
) -> Result<(), DomainError> {
let connection =
DbConnection::open_file(database_path).map_err(|error| DomainError::Storage {
message: format!("Failed to open database: {error}"),
repair: Some("ee doctor".to_string()),
})?;
let observation_id = stable_learning_observation_id(input);
connection
.insert_learning_observation(
&observation_id,
&CreateLearningObservationInput {
workspace_id: workspace_id.to_string(),
observation_kind: input.observation_kind.to_string(),
source_type: input.source_type.to_string(),
source_id: input.source_id.clone(),
target_type: input.target_type.to_string(),
target_id: input.target_id.to_string(),
topic: input.topic.clone(),
signal: input.signal.to_string(),
evidence_json: input.evidence_json.clone(),
observed_at: input.observed_at.to_string(),
},
)
.map_err(|error| DomainError::Storage {
message: format!("Failed to insert learning observation: {error}"),
repair: Some("ee learn summary --json".to_string()),
})?;
connection.close().map_err(|error| DomainError::Storage {
message: format!("Failed to close database: {error}"),
repair: Some("ee doctor".to_string()),
})?;
Ok(())
}
fn stable_learning_observation_id(input: &LearningObservationLedgerInput<'_>) -> String {
let payload = serde_json::json!({
"kind": input.observation_kind,
"sourceType": input.source_type,
"sourceId": &input.source_id,
"targetType": input.target_type,
"targetId": input.target_id,
"observedAt": input.observed_at,
});
format!(
"lobs_{}",
blake3::hash(payload.to_string().as_bytes())
.to_hex()
.chars()
.take(32)
.collect::<String>()
)
}
fn build_learning_clusters(
snapshot: &LearningSnapshot,
topic_filter: Option<&str>,
events: &[StoredFeedbackEvent],
) -> Vec<LearningCluster> {
let normalized_filter = topic_filter.map(normalize_topic);
let mut clusters = BTreeMap::new();
for event in events {
let evidence_ids = evidence_ids_for_event(event);
let memory_ids = memory_ids_for_event(snapshot, event, &evidence_ids);
let topic = topic_for_event(snapshot, event, &memory_ids);
if normalized_filter
.as_ref()
.is_some_and(|filter| &topic != filter)
{
continue;
}
let previews = previews_for_memories(snapshot, &memory_ids, event);
clusters
.entry(topic.clone())
.or_insert_with(|| LearningCluster::new(topic))
.record(event, evidence_ids, memory_ids, previews);
}
clusters.into_values().collect()
}
fn evidence_ids_for_event(event: &StoredFeedbackEvent) -> BTreeSet<String> {
let mut ids = BTreeSet::new();
ids.insert(event.target_id.clone());
if let Some(source_id) = &event.source_id {
ids.insert(source_id.clone());
}
if let Some(evidence_json) = &event.evidence_json {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(evidence_json) {
collect_evidence_ids(&value, &mut ids);
}
}
ids
}
fn collect_evidence_ids(value: &serde_json::Value, ids: &mut BTreeSet<String>) {
match value {
serde_json::Value::Array(values) => {
for value in values {
collect_evidence_ids(value, ids);
}
}
serde_json::Value::Object(object) => {
for (key, value) in object {
if matches!(
key.as_str(),
"evidenceIds"
| "promotedArtifactIds"
| "demotedArtifactIds"
| "targetArtifactIds"
| "auditIds"
) {
if let Some(values) = value.as_array() {
ids.extend(
values
.iter()
.filter_map(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
);
}
}
collect_evidence_ids(value, ids);
}
}
_ => {}
}
}
fn memory_ids_for_event(
snapshot: &LearningSnapshot,
event: &StoredFeedbackEvent,
evidence_ids: &BTreeSet<String>,
) -> BTreeSet<String> {
let mut memory_ids = BTreeSet::new();
if event.target_type == "memory" && snapshot.memories.contains_key(&event.target_id) {
memory_ids.insert(event.target_id.clone());
}
memory_ids.extend(
evidence_ids
.iter()
.filter(|id| snapshot.memories.contains_key(id.as_str()))
.cloned(),
);
memory_ids
}
fn topic_for_event(
snapshot: &LearningSnapshot,
event: &StoredFeedbackEvent,
memory_ids: &BTreeSet<String>,
) -> String {
memory_ids
.iter()
.filter_map(|memory_id| {
snapshot
.memory_tags
.get(memory_id)
.and_then(|tags| tags.iter().find(|tag| !tag.trim().is_empty()))
.cloned()
.or_else(|| {
snapshot
.memories
.get(memory_id)
.map(|memory| memory.kind.clone())
})
})
.map(|topic| normalize_topic(&topic))
.find(|topic| topic != "general")
.unwrap_or_else(|| normalize_topic(&event.target_type))
}
fn previews_for_memories(
snapshot: &LearningSnapshot,
memory_ids: &BTreeSet<String>,
event: &StoredFeedbackEvent,
) -> BTreeSet<String> {
let mut previews = memory_ids
.iter()
.filter_map(|memory_id| snapshot.memories.get(memory_id))
.map(|memory| preview_text(&memory.content, 120))
.collect::<BTreeSet<_>>();
if previews.is_empty() {
if let Some(reason) = &event.reason {
previews.insert(preview_text(reason, 120));
}
}
previews
}
fn preview_text(raw: &str, max_chars: usize) -> String {
let normalized = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.chars().count() <= max_chars {
normalized
} else {
format!(
"{}...",
normalized
.chars()
.take(max_chars.saturating_sub(3))
.collect::<String>()
)
}
}
fn normalize_topic(raw: &str) -> String {
let mut topic = raw
.trim()
.to_ascii_lowercase()
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
ch
} else {
'_'
}
})
.collect::<String>();
while topic.contains("__") {
topic = topic.replace("__", "_");
}
let topic = topic.trim_matches('_').to_string();
if topic.is_empty() {
"general".to_string()
} else {
topic
}
}
fn signal_bucket(signal: &str) -> SignalBucket {
match signal {
"positive" | "helpful" | "confirmation" => SignalBucket::Positive,
"negative" | "harmful" | "contradiction" | "inaccurate" => SignalBucket::Negative,
"stale" | "outdated" => SignalBucket::Decay,
_ => SignalBucket::Neutral,
}
}
fn is_negative_signal(signal: &str) -> bool {
matches!(
signal,
"negative" | "harmful" | "contradiction" | "inaccurate" | "outdated" | "stale"
)
}
fn feedback_impact(signal: &str) -> &'static str {
match signal_bucket(signal) {
SignalBucket::Positive => "promotes confidence",
SignalBucket::Negative => "requires review",
SignalBucket::Decay => "lowers freshness",
SignalBucket::Neutral => "adds evidence",
}
}
fn stable_suffix(namespace: &str, value: &str, len: usize) -> String {
let hash = blake3::hash(format!("{namespace}:{value}").as_bytes());
hash.to_hex().chars().take(len).collect()
}
fn budget_class(attention_tokens: u32, runtime_seconds: u32) -> &'static str {
if attention_tokens <= 600 && runtime_seconds <= 120 {
"small"
} else if attention_tokens <= 1_500 && runtime_seconds <= 600 {
"medium"
} else {
"large"
}
}
fn safety_plan(boundary: ExperimentSafetyBoundary) -> ExperimentSafetyPlan {
let boundary_name = boundary.as_str().to_string();
let mutation_allowed = false;
let review_required = matches!(
boundary,
ExperimentSafetyBoundary::AskBeforeActing | ExperimentSafetyBoundary::HumanReview
);
let denied_reasons = if boundary == ExperimentSafetyBoundary::Denied {
vec!["Safety boundary denies experiment execution.".to_string()]
} else {
Vec::new()
};
ExperimentSafetyPlan {
boundary: boundary_name,
dry_run_first: true,
mutation_allowed,
review_required,
stop_conditions: vec![
"Stop after the replay produces a pass/fail explanation or safety finding.".to_string(),
"Stop before any durable memory mutation; close with observe/close evidence first."
.to_string(),
],
denied_reasons,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{
CreateAuditInput, CreateCurationCandidateInput, CreateFeedbackEventInput,
CreateMemoryInput, CreateWorkspaceInput, DbConnection,
};
use crate::models::TrustClass;
use std::fs;
type TestResult = Result<(), String>;
fn seed_learning_database(prefix: &str) -> Result<(tempfile::TempDir, PathBuf), String> {
let dir = tempfile::Builder::new()
.prefix(prefix)
.tempdir()
.map_err(|error| error.to_string())?;
let database = dir.path().join(".ee").join("ee.db");
if let Some(parent) = database.parent() {
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
connection.migrate().map_err(|error| error.to_string())?;
connection.close().map_err(|error| error.to_string())?;
Ok((dir, database))
}
fn seed_learning_workspace(
prefix: &str,
) -> Result<(tempfile::TempDir, PathBuf, String), String> {
let (dir, database) = seed_learning_database(prefix)?;
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
let workspace_path = dir.path().to_string_lossy().into_owned();
let workspace_id = stable_workspace_id(&workspace_path);
connection
.insert_workspace(
&workspace_id,
&CreateWorkspaceInput {
path: workspace_path,
name: Some(prefix.to_string()),
},
)
.map_err(|error| error.to_string())?;
connection.close().map_err(|error| error.to_string())?;
Ok((dir, database, workspace_id))
}
fn ensure_threshold(actual: f32, expected: f32, context: &str) -> TestResult {
if (actual - expected).abs() <= f32::EPSILON {
Ok(())
} else {
Err(format!("{context}: expected {expected}, got {actual}"))
}
}
fn insert_query_miss_audit(
database: &Path,
workspace_id: &str,
id: &str,
query_hash: &str,
reason: &str,
origin: Option<&str>,
redacted_query: Option<&str>,
) -> TestResult {
let connection = DbConnection::open_file(database).map_err(|error| error.to_string())?;
let mut details = serde_json::json!({
"schema": "ee.search.query_miss.v1",
"queryHash": query_hash,
"reason": reason,
"queryTextStored": false,
"queryVectorStored": false,
});
if let Some(origin) = origin {
details["origin"] = serde_json::Value::String(origin.to_string());
}
if let Some(redacted_query) = redacted_query {
details["redactedQuery"] = serde_json::Value::String(redacted_query.to_string());
}
connection
.insert_audit(
id,
&CreateAuditInput {
workspace_id: Some(workspace_id.to_string()),
actor: None,
action: audit_actions::SEARCH_MISS_RECORDED.to_string(),
target_type: Some("query_hash".to_string()),
target_id: Some(query_hash.to_string()),
details: Some(details.to_string()),
},
)
.map_err(|error| error.to_string())?;
connection.close().map_err(|error| error.to_string())
}
fn insert_memory_for_gap_candidate(
database: &Path,
workspace_id: &str,
memory_id: &str,
) -> TestResult {
let connection = DbConnection::open_file(database).map_err(|error| error.to_string())?;
connection
.insert_memory(
memory_id,
&CreateMemoryInput {
workspace_id: workspace_id.to_string(),
level: "semantic".to_string(),
kind: "fact".to_string(),
content: "Seed memory for a query-miss gap candidate.".to_string(),
workflow_id: None,
confidence: 0.8,
utility: 0.5,
importance: 0.5,
provenance_uri: Some("test://learn-gaps".to_string()),
trust_class: "human_explicit".to_string(),
trust_subclass: None,
tags: Vec::new(),
valid_from: None,
valid_to: None,
},
)
.map_err(|error| error.to_string())?;
connection.close().map_err(|error| error.to_string())
}
fn insert_pending_gap_candidate(
database: &Path,
workspace_id: &str,
candidate_id: &str,
memory_id: &str,
query_hash: &str,
) -> TestResult {
let connection = DbConnection::open_file(database).map_err(|error| error.to_string())?;
connection
.insert_curation_candidate(
candidate_id,
&CreateCurationCandidateInput {
workspace_id: workspace_id.to_string(),
candidate_type: "rule".to_string(),
target_memory_id: Some(memory_id.to_string()),
proposed_content: Some(format!("Close query gap for {query_hash}.")),
proposed_confidence: Some(0.7),
proposed_trust_class: Some(TrustClass::AgentAssertion.as_str().to_string()),
source_type: "agent_inference".to_string(),
source_id: Some(query_hash.to_string()),
reason: format!("Repeated query miss for {query_hash}."),
confidence: 0.7,
status: Some("pending".to_string()),
created_at: None,
ttl_expires_at: None,
derivation_source_refs_json: None,
derivation_metadata_json: None,
},
)
.map_err(|error| error.to_string())?;
connection.close().map_err(|error| error.to_string())
}
#[test]
fn learn_cluster_threshold_reads_workspace_config() -> TestResult {
let workspace = tempfile::Builder::new()
.prefix("ee-learn-config-")
.tempdir()
.map_err(|error| error.to_string())?;
fs::create_dir_all(workspace.path().join(".ee")).map_err(|error| error.to_string())?;
fs::write(
workspace.path().join(".ee").join("config.toml"),
"[learn]\ncluster_coherence_threshold = 0.42\n",
)
.map_err(|error| error.to_string())?;
ensure_threshold(
resolve_learn_cluster_threshold(workspace.path(), None).map_err(|e| e.to_string())?,
0.42,
"workspace learn cluster threshold",
)
}
#[test]
fn learn_gaps_no_miss_data_is_honest_degradation() -> TestResult {
let (workspace, _database, _workspace_id) = seed_learning_workspace("ee-learn-gaps-empty")?;
let report = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: None,
limit: 10,
})
.map_err(|error| error.to_string())?;
if report.cluster_count != 0 || !report.gaps.is_empty() {
return Err(format!("expected no gaps, got {:?}", report.gaps));
}
if !report
.degraded
.iter()
.any(|entry| entry.code == LEARN_GAPS_NO_MISS_DATA)
{
return Err(format!(
"expected {LEARN_GAPS_NO_MISS_DATA}, got {:?}",
report.degraded
));
}
Ok(())
}
#[test]
fn learn_gaps_does_not_promote_one_off_misses() -> TestResult {
let (workspace, database, workspace_id) = seed_learning_workspace("ee-learn-gaps-one-off")?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000001",
"hash_one",
"no_relevant_results",
Some("search"),
None,
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000002",
"hash_two",
"weak_query_recall",
Some("ask"),
None,
)?;
let report = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: None,
limit: 10,
})
.map_err(|error| error.to_string())?;
if report.scanned_miss_count != 2 {
return Err(format!(
"expected two scanned misses, got {}",
report.scanned_miss_count
));
}
if report.cluster_count != 0 || !report.gaps.is_empty() {
return Err(format!("one-off misses became gaps: {:?}", report.gaps));
}
if report
.degraded
.iter()
.any(|entry| entry.code == LEARN_GAPS_NO_MISS_DATA)
{
return Err(format!(
"miss rows existed, but no-data degradation was emitted: {:?}",
report.degraded
));
}
Ok(())
}
#[test]
fn learn_gaps_groups_ranks_and_splits_origin_demand() -> TestResult {
let (workspace, database, workspace_id) = seed_learning_workspace("ee-learn-gaps-ranking")?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000001",
"hash_alpha",
"no_relevant_results",
Some("search"),
None,
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000002",
"hash_alpha",
"weak_query_recall",
Some("ask"),
None,
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000003",
"hash_alpha",
"no_relevant_results",
Some("search"),
None,
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000004",
"hash_beta",
"weak_query_recall",
None,
None,
)?;
let first = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: None,
limit: 10,
})
.map_err(|error| error.to_string())?;
let second = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: None,
limit: 10,
})
.map_err(|error| error.to_string())?;
let gap = first
.gaps
.first()
.ok_or_else(|| "expected ranked gap".to_string())?;
if first.cluster_count != 1 || first.gaps.len() != 1 {
return Err(format!(
"expected only repeated hash_alpha to become a gap, got {:?}",
first.gaps
));
}
if gap.query_hash != "hash_alpha" {
return Err(format!("expected hash_alpha first, got {}", gap.query_hash));
}
if gap.miss_count != 3 {
return Err(format!("expected 3 misses, got {}", gap.miss_count));
}
let origins = gap
.origins
.iter()
.map(|origin| (origin.origin.as_str(), origin.miss_count))
.collect::<Vec<_>>();
if origins != vec![("search", 2), ("ask", 1)] {
return Err(format!("unexpected origins: {origins:?}"));
}
if gap.reasons.iter().map(String::as_str).collect::<Vec<_>>()
!= vec!["no_relevant_results", "weak_query_recall"]
{
return Err(format!("unexpected reasons: {:?}", gap.reasons));
}
if first
.gaps
.iter()
.map(|gap| gap.query_hash.as_str())
.collect::<Vec<_>>()
!= second
.gaps
.iter()
.map(|gap| gap.query_hash.as_str())
.collect::<Vec<_>>()
{
return Err("learn gaps ordering changed between identical reads".to_string());
}
Ok(())
}
#[test]
fn learn_gaps_clusters_redacted_query_paraphrases_deterministically() -> TestResult {
let (workspace, database, workspace_id) =
seed_learning_workspace("ee-learn-gaps-text-cluster")?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000004",
"hash_text_a",
"weak_query_recall",
Some("search"),
Some("how do I rotate release logs"),
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000005",
"hash_text_b",
"weak_query_recall",
Some("search"),
Some("rotate logs for release"),
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000006",
"hash_text_c",
"weak_query_recall",
Some("ask"),
Some("release logs rotate"),
)?;
let report = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: None,
limit: 10,
})
.map_err(|error| error.to_string())?;
if report.cluster_count != 1 {
return Err(format!(
"expected one text cluster, got {}",
report.cluster_count
));
}
let gap = report
.gaps
.first()
.ok_or_else(|| "expected text-clustered gap".to_string())?;
if gap.query_hashes
!= vec![
"hash_text_a".to_string(),
"hash_text_b".to_string(),
"hash_text_c".to_string(),
]
{
return Err(format!(
"unexpected clustered hashes: {:?}",
gap.query_hashes
));
}
if gap.miss_count != 3 {
return Err(format!(
"expected three clustered misses, got {}",
gap.miss_count
));
}
Ok(())
}
#[test]
fn learn_gaps_flip_to_likely_covered_when_a_newer_similar_memory_lands() -> TestResult {
let (workspace, database, workspace_id) = seed_learning_workspace("ee-learn-gaps-covered")?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000014",
"hash_covered_a",
"weak_query_recall",
Some("search"),
Some("zebra hovercraft docking protocol"),
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000015",
"hash_covered_a",
"weak_query_recall",
Some("search"),
Some("zebra hovercraft docking protocol"),
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000016",
"hash_covered_a",
"weak_query_recall",
Some("search"),
Some("zebra hovercraft docking protocol"),
)?;
let report = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: None,
limit: 10,
})
.map_err(|error| error.to_string())?;
let gap = report
.gaps
.first()
.ok_or_else(|| "expected the miss cluster".to_string())?;
if gap.status != "open" || gap.covered_by.is_some() {
return Err(format!(
"expected an open cluster before coverage, got {} / {:?}",
gap.status, gap.covered_by
));
}
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
connection
.insert_memory(
"mem_00000000000000000000000071",
&CreateMemoryInput {
workspace_id: workspace_id.clone(),
level: "semantic".to_string(),
kind: "fact".to_string(),
content: "Entirely unrelated release-notes bookkeeping detail.".to_string(),
workflow_id: None,
confidence: 0.8,
utility: 0.5,
importance: 0.5,
provenance_uri: None,
trust_class: "human_explicit".to_string(),
trust_subclass: None,
tags: Vec::new(),
valid_from: None,
valid_to: None,
},
)
.map_err(|error| error.to_string())?;
let report = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: None,
limit: 10,
})
.map_err(|error| error.to_string())?;
let gap = report
.gaps
.first()
.ok_or_else(|| "cluster survived the unrelated memory".to_string())?;
if gap.status != "open" {
return Err(format!(
"dissimilar memory must not cover the gap: {} via {:?}",
gap.status, gap.covered_by
));
}
connection
.insert_memory(
"mem_00000000000000000000000072",
&CreateMemoryInput {
workspace_id: workspace_id.clone(),
level: "procedural".to_string(),
kind: "rule".to_string(),
content: "Zebra hovercraft docking protocol: engage magnetic clamps before \
the hovercraft docking sequence starts."
.to_string(),
workflow_id: None,
confidence: 0.8,
utility: 0.5,
importance: 0.5,
provenance_uri: None,
trust_class: "human_explicit".to_string(),
trust_subclass: None,
tags: Vec::new(),
valid_from: None,
valid_to: None,
},
)
.map_err(|error| error.to_string())?;
connection.close().map_err(|error| error.to_string())?;
let report = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: None,
limit: 10,
})
.map_err(|error| error.to_string())?;
let gap = report
.gaps
.first()
.ok_or_else(|| "cluster must remain listed after coverage".to_string())?;
if gap.status != "likely_covered" {
return Err(format!(
"expected likely_covered after the similar newer memory, got {} (evidence: {:?})",
gap.status, gap.nearest_existing_evidence
));
}
if gap.covered_by.as_deref() != Some("mem_00000000000000000000000072") {
return Err(format!(
"coveredBy must name the memory: {:?}",
gap.covered_by
));
}
Ok(())
}
#[test]
fn learn_gaps_templates_redact_and_cross_link_agenda_item() -> TestResult {
let (workspace, database, workspace_id) =
seed_learning_workspace("ee-learn-gaps-template")?;
insert_memory_for_gap_candidate(
&database,
&workspace_id,
"mem_gaptemplate000000000000001",
)?;
insert_pending_gap_candidate(
&database,
&workspace_id,
"curate_gggggggggggggggggggggggggg",
"mem_gaptemplate000000000000001",
"hash_template",
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000004",
"hash_template",
"no_relevant_results",
Some("ask"),
Some("how do I rotate ghp_0123456789abcdef0123456789abcdef01234567"),
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000005",
"hash_template",
"no_relevant_results",
Some("ask"),
Some("how do I rotate ghp_0123456789abcdef0123456789abcdef01234567"),
)?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000006",
"hash_template",
"no_relevant_results",
Some("search"),
Some("how do I rotate ghp_0123456789abcdef0123456789abcdef01234567"),
)?;
let report = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: None,
limit: 10,
})
.map_err(|error| error.to_string())?;
let gap = report
.gaps
.first()
.ok_or_else(|| "expected template gap".to_string())?;
if gap.remember_template.suggested_level != "procedural"
|| gap.remember_template.suggested_kind != "rule"
{
return Err(format!(
"expected procedural rule template, got {:?}",
gap.remember_template
));
}
let preview = gap
.representative_redacted_queries
.first()
.ok_or_else(|| "expected redacted query preview".to_string())?;
if preview.contains("ghp_0123456789abcdef") {
return Err(format!(
"secret-like query material was not redacted: {preview}"
));
}
if gap.nearest_existing_evidence_status != "hash_embedder_scan" {
return Err(format!(
"expected real nearest-evidence scan, got {}",
gap.nearest_existing_evidence_status
));
}
if !gap
.nearest_existing_evidence
.iter()
.any(|evidence| evidence.memory_id == "mem_gaptemplate000000000000001")
{
return Err(format!(
"expected seeded memory in nearest evidence, got {:?}",
gap.nearest_existing_evidence
));
}
if gap.matching_agenda_item.as_deref() != Some("curate_gggggggggggggggggggggggggg") {
return Err(format!(
"expected agenda cross-link, got {:?}",
gap.matching_agenda_item
));
}
Ok(())
}
#[test]
fn learn_gaps_retention_short_clamps_effective_since() -> TestResult {
let (workspace, database, workspace_id) =
seed_learning_workspace("ee-learn-gaps-retention")?;
fs::create_dir_all(workspace.path().join(".ee")).map_err(|error| error.to_string())?;
fs::write(
workspace.path().join(".ee").join("config.toml"),
"[search]\nquery_miss_retention_days = 1\n",
)
.map_err(|error| error.to_string())?;
insert_query_miss_audit(
&database,
&workspace_id,
"audit_00000000000000000000000005",
"hash_retention",
"weak_query_recall",
Some("search"),
None,
)?;
let report = show_gaps(&LearnGapsOptions {
workspace: workspace.path().to_path_buf(),
since: Some("2020-01-01T00:00:00Z".to_string()),
limit: 10,
})
.map_err(|error| error.to_string())?;
if report.retention_days != 1 {
return Err(format!(
"expected retention 1, got {}",
report.retention_days
));
}
if report.effective_since == "2020-01-01T00:00:00+00:00" {
return Err("effective since was not clamped to retention cutoff".to_string());
}
if !report
.degraded
.iter()
.any(|entry| entry.code == LEARN_GAPS_RETENTION_SHORT)
{
return Err(format!(
"expected {LEARN_GAPS_RETENTION_SHORT}, got {:?}",
report.degraded
));
}
Ok(())
}
#[test]
fn learn_gaps_rejects_retention_duration_overflow() -> TestResult {
let newest = DateTime::parse_from_rfc3339("2026-06-01T00:00:00Z")
.map_err(|error| error.to_string())?
.with_timezone(&Utc);
let error = query_miss_retention_cutoff(newest, u64::MAX)
.expect_err("oversized query-miss retention must fail closed");
if error.code() != "configuration" {
return Err(format!(
"expected configuration error, got {}",
error.code()
));
}
if !error.message().contains("exceeds supported duration range") {
return Err(format!("unexpected error message: {}", error.message()));
}
Ok(())
}
#[cfg(unix)]
#[test]
fn learn_cluster_threshold_ignores_symlinked_workspace_config() -> TestResult {
let workspace = tempfile::Builder::new()
.prefix("ee-learn-symlink-config-")
.tempdir()
.map_err(|error| error.to_string())?;
let outside = tempfile::Builder::new()
.prefix("ee-learn-symlink-outside-")
.tempdir()
.map_err(|error| error.to_string())?;
fs::create_dir_all(workspace.path().join(".ee")).map_err(|error| error.to_string())?;
fs::write(
outside.path().join("config.toml"),
"[learn]\ncluster_coherence_threshold = 0.12\n",
)
.map_err(|error| error.to_string())?;
std::os::unix::fs::symlink(
outside.path().join("config.toml"),
workspace.path().join(".ee").join("config.toml"),
)
.map_err(|error| error.to_string())?;
ensure_threshold(
resolve_learn_cluster_threshold(workspace.path(), None).map_err(|e| e.to_string())?,
DEFAULT_LEARN_CLUSTER_COHERENCE_THRESHOLD,
"symlinked workspace learn config fallback",
)
}
#[cfg(unix)]
#[test]
fn learn_cluster_threshold_ignores_symlinked_workspace_config_parent() -> TestResult {
let workspace = tempfile::Builder::new()
.prefix("ee-learn-symlink-parent-")
.tempdir()
.map_err(|error| error.to_string())?;
let outside = tempfile::Builder::new()
.prefix("ee-learn-symlink-parent-outside-")
.tempdir()
.map_err(|error| error.to_string())?;
fs::write(
outside.path().join("config.toml"),
"[learn]\ncluster_coherence_threshold = 0.12\n",
)
.map_err(|error| error.to_string())?;
std::os::unix::fs::symlink(outside.path(), workspace.path().join(".ee"))
.map_err(|error| error.to_string())?;
ensure_threshold(
resolve_learn_cluster_threshold(workspace.path(), None).map_err(|e| e.to_string())?,
DEFAULT_LEARN_CLUSTER_COHERENCE_THRESHOLD,
"symlinked workspace learn config parent fallback",
)
}
fn seed_memory(
connection: &DbConnection,
workspace_id: &str,
id: &str,
tag: &str,
content: &str,
) -> TestResult {
connection
.insert_memory(
id,
&CreateMemoryInput {
workspace_id: workspace_id.to_string(),
level: "episodic".to_string(),
kind: "procedure".to_string(),
content: content.to_string(),
workflow_id: None,
confidence: 0.5,
utility: 0.5,
importance: 0.5,
provenance_uri: Some(format!("test://{id}")),
trust_class: "agent_assertion".to_string(),
trust_subclass: None,
tags: vec![tag.to_string()],
valid_from: None,
valid_to: None,
},
)
.map_err(|error| error.to_string())
}
fn seed_feedback(
connection: &DbConnection,
workspace_id: &str,
id: &str,
memory_id: &str,
signal: &str,
) -> TestResult {
connection
.insert_feedback_event(
id,
&CreateFeedbackEventInput {
workspace_id: workspace_id.to_string(),
target_type: "memory".to_string(),
target_id: memory_id.to_string(),
signal: signal.to_string(),
weight: 1.0,
source_type: "outcome_observed".to_string(),
source_id: Some(format!("outcome_{id}")),
reason: Some(format!("{signal} outcome for {memory_id}")),
evidence_json: Some(
serde_json::json!({
"evidenceIds": [memory_id],
"status": signal,
})
.to_string(),
),
session_id: None,
},
)
.map_err(|error| error.to_string())
}
fn seed_summary_candidate(
connection: &DbConnection,
workspace_id: &str,
memory_id: &str,
id: &str,
created_at: &str,
) -> TestResult {
connection
.insert_curation_candidate(
id,
&CreateCurationCandidateInput {
workspace_id: workspace_id.to_string(),
candidate_type: "rule".to_string(),
target_memory_id: Some(memory_id.to_string()),
proposed_content: Some(format!("Learned summary candidate {id}.")),
proposed_confidence: Some(0.7),
proposed_trust_class: Some(TrustClass::AgentAssertion.as_str().to_string()),
source_type: "agent_inference".to_string(),
source_id: Some(id.to_string()),
reason: format!("Summary period regression candidate {id}."),
confidence: 0.7,
status: Some("pending".to_string()),
created_at: Some(created_at.to_string()),
ttl_expires_at: None,
derivation_source_refs_json: None,
derivation_metadata_json: None,
},
)
.map_err(|error| error.to_string())
}
#[test]
fn agenda_empty_ledger_returns_empty_report() -> TestResult {
let (dir, _database, _workspace_id) = seed_learning_workspace("ee-learn-empty")?;
let report = show_agenda(&LearnAgendaOptions {
workspace: dir.path().to_path_buf(),
limit: 10,
include_resolved: false,
..Default::default()
})
.map_err(|error| error.message())?;
assert_eq!(report.schema, LEARN_AGENDA_SCHEMA_V1);
assert!(report.items.is_empty());
assert_eq!(report.total_gaps, 0);
Ok(())
}
#[test]
fn agenda_clusters_single_observation_with_sample_ids() -> TestResult {
let (dir, database, workspace_id) = seed_learning_workspace("ee-learn-single")?;
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
seed_memory(
&connection,
&workspace_id,
"mem_11234567890123456789012345",
"testing",
"Run the database contract fixture before promoting test guidance.",
)?;
seed_feedback(
&connection,
&workspace_id,
"fb_11234567890123456789012345",
"mem_11234567890123456789012345",
"confirmation",
)?;
connection.close().map_err(|error| error.to_string())?;
let report = show_agenda(&LearnAgendaOptions {
workspace: dir.path().to_path_buf(),
limit: 10,
include_resolved: true,
..Default::default()
})
.map_err(|error| error.message())?;
assert_eq!(report.items.len(), 1);
let item = &report.items[0];
assert_eq!(item.topic, "testing");
assert!(
item.sample_ids
.contains(&"fb_11234567890123456789012345".to_string())
);
assert!(
item.sample_ids
.contains(&"mem_11234567890123456789012345".to_string())
);
assert!(item.uncertainty >= 0.7);
Ok(())
}
#[test]
fn cluster_recency_uses_latest_event_time_not_report_time() {
fn feedback_at(id: &str, created_at: &str) -> StoredFeedbackEvent {
StoredFeedbackEvent {
id: id.to_string(),
workspace_id: "ws_recency".to_string(),
target_type: "memory".to_string(),
target_id: "mem_recency".to_string(),
signal: "confirmation".to_string(),
weight: 1.0,
source_type: "outcome_observed".to_string(),
source_id: None,
reason: None,
evidence_json: None,
session_id: None,
applied_at: None,
created_at: created_at.to_string(),
}
}
let old = "2026-01-01T00:00:00Z";
let mut cluster = LearningCluster::new("testing".to_string());
cluster.record(
&feedback_at("fb_old", old),
BTreeSet::new(),
BTreeSet::new(),
BTreeSet::new(),
);
assert_eq!(cluster.last_seen_at, old);
assert_eq!(cluster.agenda_item().created_at, old);
assert_eq!(
cluster.uncertainty_item().last_accessed.as_deref(),
Some(old)
);
let newer = "2026-03-15T12:00:00Z";
cluster.record(
&feedback_at("fb_newer", newer),
BTreeSet::new(),
BTreeSet::new(),
BTreeSet::new(),
);
assert_eq!(cluster.agenda_item().created_at, newer);
cluster.record(
&feedback_at("fb_older", "2025-06-01T00:00:00Z"),
BTreeSet::new(),
BTreeSet::new(),
BTreeSet::new(),
);
assert_eq!(cluster.agenda_item().created_at, newer);
}
#[test]
fn uncertainty_detects_contradictory_observations() -> TestResult {
let (dir, database, workspace_id) = seed_learning_workspace("ee-learn-contradict")?;
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
seed_memory(
&connection,
&workspace_id,
"mem_21234567890123456789012345",
"review",
"Promote review-session candidates only after evidence aggregation.",
)?;
seed_feedback(
&connection,
&workspace_id,
"fb_21234567890123456789012345",
"mem_21234567890123456789012345",
"confirmation",
)?;
seed_feedback(
&connection,
&workspace_id,
"fb_22234567890123456789012345",
"mem_21234567890123456789012345",
"contradiction",
)?;
connection.close().map_err(|error| error.to_string())?;
let report = show_uncertainty(&LearnUncertaintyOptions {
workspace: dir.path().to_path_buf(),
limit: 10,
min_uncertainty: 0.0,
kind: Some("review".to_string()),
low_confidence: false,
})
.map_err(|error| error.message())?;
assert_eq!(report.items.len(), 1);
assert!(report.items[0].uncertainty >= 0.6);
assert!(report.items[0].confidence < 0.6);
Ok(())
}
#[test]
fn summary_aggregates_learning_observations_and_candidates() -> TestResult {
let (dir, database, workspace_id) = seed_learning_workspace("ee-learn-summary")?;
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
seed_memory(
&connection,
&workspace_id,
"mem_31234567890123456789012345",
"summary",
"Summarize learning signals from feedback and curation rows.",
)?;
seed_feedback(
&connection,
&workspace_id,
"fb_31234567890123456789012345",
"mem_31234567890123456789012345",
"helpful",
)?;
seed_feedback(
&connection,
&workspace_id,
"fb_32234567890123456789012345",
"mem_31234567890123456789012345",
"harmful",
)?;
connection.close().map_err(|error| error.to_string())?;
let report = show_summary(&LearnSummaryOptions {
workspace: dir.path().to_path_buf(),
period: "all".to_string(),
since: None,
detailed: true,
})
.map_err(|error| error.message())?;
assert_eq!(report.summary.observations_recorded, 2);
assert_eq!(report.summary.harmful_feedback_count, 1);
assert_eq!(report.summary.gaps_identified, 1);
assert_eq!(report.events.len(), 2);
Ok(())
}
#[test]
fn summary_period_filters_candidate_history_without_explicit_since() -> TestResult {
let (dir, database, workspace_id) = seed_learning_workspace("ee-learn-summary-period")?;
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
let memory_id = "mem_41234567890123456789012345";
seed_memory(
&connection,
&workspace_id,
memory_id,
"summary-period",
"Learning summary period target.",
)?;
seed_summary_candidate(
&connection,
&workspace_id,
memory_id,
"curate_00000000000000000000000101",
"2000-01-01T00:00:00Z",
)?;
seed_summary_candidate(
&connection,
&workspace_id,
memory_id,
"curate_00000000000000000000000102",
"9999-01-01T00:00:00Z",
)?;
connection.close().map_err(|error| error.to_string())?;
let all = show_summary(&LearnSummaryOptions {
workspace: dir.path().to_path_buf(),
period: "all".to_string(),
since: None,
detailed: false,
})
.map_err(|error| error.message())?;
assert_eq!(all.summary.candidates_proposed, 2);
assert_eq!(all.summary.rules_learned, 2);
for period in ["today", "week", "month"] {
let report = show_summary(&LearnSummaryOptions {
workspace: dir.path().to_path_buf(),
period: period.to_string(),
since: None,
detailed: false,
})
.map_err(|error| error.message())?;
assert_eq!(report.summary.period, period);
assert_eq!(report.summary.candidates_proposed, 1);
assert_eq!(report.summary.rules_learned, 1);
}
Ok(())
}
#[test]
fn summary_rejects_invalid_since_before_storage_lookup() -> TestResult {
let error = show_summary(&LearnSummaryOptions {
workspace: PathBuf::from("/workspace/does-not-need-to-exist"),
period: "all".to_string(),
since: Some("not-a-date".to_string()),
detailed: false,
})
.expect_err("invalid explicit since should fail before storage lookup");
if !matches!(&error, DomainError::Usage { .. }) {
return Err(format!("expected usage error, got {error:?}"));
}
if !error.message().contains("Invalid --since `not-a-date`") {
return Err(format!("unexpected error message: {}", error.message()));
}
Ok(())
}
#[test]
fn summary_since_filters_mixed_rfc3339_forms_chronologically() -> TestResult {
let (dir, database, workspace_id) =
seed_learning_workspace("ee-learn-summary-since-rfc3339")?;
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
let memory_id = "mem_51234567890123456789012345";
seed_memory(
&connection,
&workspace_id,
memory_id,
"summary-since",
"Learning summary since filtering target.",
)?;
seed_summary_candidate(
&connection,
&workspace_id,
memory_id,
"curate_00000000000000000000000103",
"2026-01-01T00:00:00Z",
)?;
seed_summary_candidate(
&connection,
&workspace_id,
memory_id,
"curate_00000000000000000000000104",
"2026-01-01T00:00:01+00:00",
)?;
connection.close().map_err(|error| error.to_string())?;
let report = show_summary(&LearnSummaryOptions {
workspace: dir.path().to_path_buf(),
period: "all".to_string(),
since: Some("2026-01-01T00:00:00.500000000+00:00".to_string()),
detailed: false,
})
.map_err(|error| error.message())?;
assert_eq!(report.summary.candidates_proposed, 1);
assert_eq!(report.summary.rules_learned, 1);
Ok(())
}
#[test]
fn summary_rejects_unknown_period() -> TestResult {
let error = show_summary(&LearnSummaryOptions {
workspace: PathBuf::from("/workspace/does-not-need-to-exist"),
period: "forever".to_string(),
since: None,
detailed: false,
})
.expect_err("unknown summary period should fail before storage lookup");
if !matches!(&error, DomainError::Usage { .. }) {
return Err(format!("expected usage error, got {error:?}"));
}
if !error
.message()
.contains("expected today, week, month, or all")
{
return Err(format!("unexpected error message: {}", error.message()));
}
Ok(())
}
#[test]
fn observe_experiment_dry_run_attaches_sorted_evidence_without_storage() -> TestResult {
let report = observe_experiment(&LearnObserveOptions {
workspace: PathBuf::from("/workspace"),
database_path: None,
workspace_id: None,
experiment_id: "exp_database_contract_fixture".to_string(),
observation_id: Some("lobs_test".to_string()),
observed_at: Some("2026-01-01T00:00:00Z".to_string()),
observer: Some("MistySalmon".to_string()),
signal: LearningObservationSignal::Positive,
measurement_name: "contract_status".to_string(),
measurement_value: Some(1.0),
evidence_ids: vec!["ev_b".to_string(), "ev_a".to_string(), "ev_a".to_string()],
note: Some("Contract fixture passed.".to_string()),
redaction_status: Some("redacted".to_string()),
session_id: None,
event_id: None,
actor: Some("MistySalmon".to_string()),
dry_run: true,
})
.map_err(|error| error.message())?;
assert_eq!(report.schema, LEARN_OBSERVE_SCHEMA_V1);
assert_eq!(report.status, "dry_run");
assert!(report.feedback.is_none());
assert_eq!(
report.observation.evidence_ids,
vec!["ev_a".to_string(), "ev_b".to_string()]
);
let json = report.data_json();
assert_eq!(json["observation"]["signal"], "positive");
assert_eq!(json["observation"]["redactionStatus"], "redacted");
Ok(())
}
#[test]
fn observe_experiment_public_json_redacts_sensitive_evidence_ids() -> TestResult {
let raw_evidence =
"/Users/alice/private/learning/evidence.json?api_key=sk-FAKEabc123def456ghi789";
let report = observe_experiment(&LearnObserveOptions {
workspace: PathBuf::from("/workspace"),
database_path: None,
workspace_id: None,
experiment_id: "exp_sensitive_evidence".to_string(),
observation_id: Some("lobs_sensitive".to_string()),
observed_at: Some("2026-01-01T00:00:00Z".to_string()),
observer: Some("MistySalmon".to_string()),
signal: LearningObservationSignal::Positive,
measurement_name: "contract_status".to_string(),
measurement_value: Some(1.0),
evidence_ids: vec![raw_evidence.to_string()],
note: Some("Contract fixture passed.".to_string()),
redaction_status: None,
session_id: None,
event_id: None,
actor: Some("MistySalmon".to_string()),
dry_run: true,
})
.map_err(|error| error.message())?;
assert_eq!(
report.observation.evidence_ids,
vec![raw_evidence.to_string()]
);
let rendered = report.data_json().to_string();
assert!(rendered.contains("[REDACTED_PATH]"));
assert!(rendered.contains("[REDACTED:"));
assert!(!rendered.contains(raw_evidence));
assert!(!rendered.contains("/Users/alice"));
assert!(!rendered.contains("sk-FAKE"));
assert_eq!(
report.data_json().pointer("/observation/redactionStatus"),
Some(&serde_json::json!("standard"))
);
Ok(())
}
#[test]
fn observe_experiment_records_feedback_and_audit() -> TestResult {
let (dir, database, _) = seed_learning_workspace("ee-learn-observe")?;
let report = observe_experiment(&LearnObserveOptions {
workspace: dir.path().to_path_buf(),
database_path: Some(database.clone()),
workspace_id: None,
experiment_id: "exp_replay_error_boundary".to_string(),
observation_id: Some("lobs_recorded".to_string()),
observed_at: Some("2026-01-01T00:00:00Z".to_string()),
observer: Some("MistySalmon".to_string()),
signal: LearningObservationSignal::Safety,
measurement_name: "safety_findings".to_string(),
measurement_value: Some(1.0),
evidence_ids: vec!["ev_safety".to_string()],
note: Some("Dry-run found unsafe mutation risk.".to_string()),
redaction_status: Some("redacted".to_string()),
session_id: None,
event_id: Some("fb_22234567890123456789012345".to_string()),
actor: Some("MistySalmon".to_string()),
dry_run: false,
})
.map_err(|error| error.message())?;
assert_eq!(report.status, "observed");
let feedback = report
.feedback
.as_ref()
.ok_or_else(|| "feedback report must be present".to_string())?;
assert_eq!(feedback.target_type, "candidate");
assert_eq!(feedback.target_id, "exp_replay_error_boundary");
assert_eq!(feedback.signal, "harmful");
assert!(feedback.evidence_json_present);
assert!(feedback.audit_id.is_some());
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
let events = connection
.list_feedback_events_for_target("candidate", "exp_replay_error_boundary")
.map_err(|error| error.to_string())?;
assert_eq!(events.len(), 1);
assert_eq!(events[0].source_id.as_deref(), Some("lobs_recorded"));
let observations = connection
.list_learning_observations(&feedback.workspace_id, None)
.map_err(|error| error.to_string())?;
assert_eq!(observations.len(), 1);
assert_eq!(observations[0].observation_kind, "experiment_observe");
connection.close().map_err(|error| error.to_string())
}
#[test]
fn close_experiment_dry_run_records_confirmed_outcome_shape() -> TestResult {
let report = close_experiment(&LearnCloseOptions {
workspace: PathBuf::from("/workspace"),
database_path: None,
workspace_id: None,
experiment_id: "exp_database_contract_fixture".to_string(),
outcome_id: Some("lout_confirmed".to_string()),
closed_at: Some("2026-01-02T00:00:00Z".to_string()),
status: ExperimentOutcomeStatus::Confirmed,
decision_impact: "Promote database fixture guidance.".to_string(),
confidence_delta: 0.25,
priority_delta: -5,
promoted_artifact_ids: vec!["mem_001".to_string()],
demoted_artifact_ids: Vec::new(),
safety_notes: vec!["No unsafe mutation observed.".to_string()],
audit_ids: vec!["audit_manual".to_string()],
session_id: None,
event_id: None,
actor: Some("MistySalmon".to_string()),
dry_run: true,
})
.map_err(|error| error.message())?;
assert_eq!(report.schema, LEARN_CLOSE_SCHEMA_V1);
assert_eq!(report.status, "dry_run");
assert_eq!(report.outcome.status, ExperimentOutcomeStatus::Confirmed);
assert_eq!(report.outcome.confidence_delta, 0.25);
assert_eq!(
report.outcome.promoted_artifact_ids,
vec!["mem_001".to_string()]
);
assert_eq!(
report.downstream_effects.mutation_mode,
"dry_run_projection"
);
assert_eq!(
report.downstream_effects.economy_score.confidence_delta,
0.25
);
assert!(!report.downstream_effects.audit.durable_feedback_recorded);
assert!(!report.downstream_effects.audit.silent_mutation);
assert!(report.feedback.is_none());
Ok(())
}
#[test]
fn close_experiment_public_json_redacts_sensitive_artifact_refs() -> TestResult {
let promoted = "/Users/alice/private/procedure.md?token=ghp_FAKEabc123def456ghi7890";
let demoted = "tw:///Volumes/Secret/tripwire.json?api_key=sk-FAKEabc123def456ghi789";
let audit = "/tmp/learning/audit.json?secret=redaction-fixture";
let report = close_experiment(&LearnCloseOptions {
workspace: PathBuf::from("/workspace"),
database_path: None,
workspace_id: None,
experiment_id: "exp_sensitive_outcome".to_string(),
outcome_id: Some("lout_sensitive".to_string()),
closed_at: Some("2026-01-02T00:00:00Z".to_string()),
status: ExperimentOutcomeStatus::Rejected,
decision_impact: "Reject sensitive local evidence.".to_string(),
confidence_delta: -0.25,
priority_delta: 4,
promoted_artifact_ids: vec![promoted.to_string()],
demoted_artifact_ids: vec![demoted.to_string()],
safety_notes: Vec::new(),
audit_ids: vec![audit.to_string()],
session_id: None,
event_id: None,
actor: Some("MistySalmon".to_string()),
dry_run: true,
})
.map_err(|error| error.message())?;
assert_eq!(
report.outcome.promoted_artifact_ids,
vec![promoted.to_string()]
);
assert_eq!(
report.outcome.demoted_artifact_ids,
vec![demoted.to_string()]
);
assert_eq!(report.outcome.audit_ids, vec![audit.to_string()]);
let rendered = report.data_json().to_string();
assert!(rendered.contains("[REDACTED_PATH]"));
assert!(rendered.contains("[REDACTED:"));
assert!(!rendered.contains(promoted));
assert!(!rendered.contains(demoted));
assert!(!rendered.contains(audit));
assert!(!rendered.contains("/Users/alice"));
assert!(!rendered.contains("/Volumes/Secret"));
assert!(!rendered.contains("/tmp/learning"));
assert!(!rendered.contains("ghp_FAKE"));
assert!(!rendered.contains("sk-FAKE"));
Ok(())
}
#[test]
fn close_experiment_projects_downstream_effects_by_artifact_kind() -> TestResult {
let report = close_experiment(&LearnCloseOptions {
workspace: PathBuf::from("/workspace"),
database_path: None,
workspace_id: None,
experiment_id: "exp_false_alarm_probe".to_string(),
outcome_id: Some("lout_false_alarm".to_string()),
closed_at: Some("2026-01-02T00:00:00Z".to_string()),
status: ExperimentOutcomeStatus::Rejected,
decision_impact: "Reject noisy tripwire and stale procedure evidence.".to_string(),
confidence_delta: -0.3,
priority_delta: 4,
promoted_artifact_ids: vec!["mem_keep_001".to_string()],
demoted_artifact_ids: vec![
"tw_noisy_001".to_string(),
"proc_release_001".to_string(),
"sit_release_001".to_string(),
],
safety_notes: Vec::new(),
audit_ids: vec!["audit_false_alarm".to_string()],
session_id: None,
event_id: None,
actor: Some("MistySalmon".to_string()),
dry_run: true,
})
.map_err(|error| error.message())?;
let effects = report.downstream_effects;
assert_eq!(effects.mutation_mode, "dry_run_projection");
assert_eq!(
effects.procedure_drift.procedure_artifact_ids,
vec!["proc_release_001".to_string()]
);
assert_eq!(
effects.procedure_drift.drift_signal,
"contradicted_by_experiment"
);
assert!(effects.procedure_drift.requires_revalidation);
assert_eq!(
effects.tripwire_false_alarm.tripwire_artifact_ids,
vec!["tw_noisy_001".to_string()]
);
assert_eq!(effects.tripwire_false_alarm.false_alarm_cost_delta, 1);
assert_eq!(
effects.situation_confidence.situation_artifact_ids,
vec!["sit_release_001".to_string()]
);
assert_eq!(
effects.situation_confidence.confidence_direction,
"decrease"
);
assert!(!effects.audit.silent_mutation);
Ok(())
}
#[test]
fn close_experiment_records_rejected_outcome_feedback() -> TestResult {
let (dir, database, _) = seed_learning_workspace("ee-learn-close")?;
let report = close_experiment(&LearnCloseOptions {
workspace: dir.path().to_path_buf(),
database_path: Some(database.clone()),
workspace_id: None,
experiment_id: "exp_cli_validation_shadow".to_string(),
outcome_id: Some("lout_rejected".to_string()),
closed_at: Some("2026-01-02T00:00:00Z".to_string()),
status: ExperimentOutcomeStatus::Rejected,
decision_impact: "Reject promotion because shadow examples contradicted it."
.to_string(),
confidence_delta: -0.4,
priority_delta: 10,
promoted_artifact_ids: Vec::new(),
demoted_artifact_ids: vec!["mem_003".to_string(), "tw_cli_noisy_001".to_string()],
safety_notes: Vec::new(),
audit_ids: Vec::new(),
session_id: None,
event_id: Some("fb_33234567890123456789012345".to_string()),
actor: Some("MistySalmon".to_string()),
dry_run: false,
})
.map_err(|error| error.message())?;
assert_eq!(report.status, "closed");
let feedback = report
.feedback
.as_ref()
.ok_or_else(|| "feedback report must be present".to_string())?;
assert_eq!(feedback.signal, "contradiction");
assert_eq!(feedback.source_type, "outcome_observed");
assert_eq!(feedback.source_id.as_deref(), Some("lout_rejected"));
assert_eq!(feedback.feedback.total_count, 1);
assert!(report.data_json()["outcome"]["demotedArtifactIds"].is_array());
assert_eq!(
report
.data_json()
.pointer("/downstreamEffects/mutationMode"),
Some(&serde_json::json!("audited_feedback_recorded"))
);
assert_eq!(
report
.data_json()
.pointer("/downstreamEffects/tripwireFalseAlarm/falseAlarmCostDelta"),
Some(&serde_json::json!(1))
);
assert_eq!(
report
.data_json()
.pointer("/downstreamEffects/audit/silentMutation"),
Some(&serde_json::json!(false))
);
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
let observations = connection
.list_learning_observations(&feedback.workspace_id, None)
.map_err(|error| error.to_string())?;
assert_eq!(observations.len(), 1);
assert_eq!(observations[0].observation_kind, "experiment_close");
connection.close().map_err(|error| error.to_string())?;
Ok(())
}
#[test]
fn close_experiment_rejects_out_of_range_confidence_delta() -> TestResult {
let result = close_experiment(&LearnCloseOptions {
workspace: PathBuf::from("/workspace"),
database_path: None,
workspace_id: None,
experiment_id: "exp_cli_validation_shadow".to_string(),
outcome_id: Some("lout_bad".to_string()),
closed_at: Some("2026-01-02T00:00:00Z".to_string()),
status: ExperimentOutcomeStatus::Unsafe,
decision_impact: "Unsafe result.".to_string(),
confidence_delta: 1.5,
priority_delta: 0,
promoted_artifact_ids: Vec::new(),
demoted_artifact_ids: Vec::new(),
safety_notes: vec!["Unsafe mutation risk.".to_string()],
audit_ids: Vec::new(),
session_id: None,
event_id: None,
actor: None,
dry_run: true,
});
assert!(result.is_err());
Ok(())
}
#[test]
fn learning_candidate_provenance_survives_backup_and_restore() -> TestResult {
use crate::core::backup::{
BackupCreateOptions, BackupRestoreOptions, create_backup, restore_backup_to_side_path,
};
use crate::core::curate::{
CurateApplyOptions, CurateValidateOptions, apply_curation_candidate_as_recipe,
validate_curation_candidate,
};
use crate::models::RedactionLevel;
for redaction in [RedactionLevel::None, RedactionLevel::Standard] {
let (dir, database, workspace_id) = seed_learning_workspace("ee-learning-recovery")?;
let db = DbConnection::open_file(&database).map_err(|e| e.to_string())?;
for index in 0..3 {
let id = format!("mem_{index:026}");
seed_memory(
&db,
&workspace_id,
&id,
"recovery_cluster",
"Before publishing artifacts, run cargo fmt --check and cargo test.",
)?;
seed_feedback(
&db,
&workspace_id,
&format!("fb_{index:026}"),
&id,
"confirmation",
)?;
}
db.close().map_err(|e| e.to_string())?;
let proposed = propose_experiments(&LearnExperimentProposeOptions {
workspace: dir.path().to_owned(),
limit: 5,
topic: Some("recovery_cluster".to_owned()),
min_expected_value: 0.0,
max_attention_tokens: 900,
max_runtime_seconds: 180,
safety_boundary: ExperimentSafetyBoundary::DryRunOnly,
})
.map_err(|e| e.message())?;
assert_eq!(proposed.proposals.len(), 1);
let db = DbConnection::open_file(&database).map_err(|e| e.to_string())?;
let candidates = db
.list_curation_candidates(&workspace_id, Some("rule"), None, None)
.map_err(|e| e.to_string())?;
assert_eq!(candidates.len(), 1);
let candidate = &candidates[0];
db.close().map_err(|e| e.to_string())?;
let validation = validate_curation_candidate(&CurateValidateOptions {
workspace_path: dir.path(),
database_path: Some(&database),
candidate_id: &candidate.id,
actor: Some("recovery-reviewer"),
dry_run: false,
})
.map_err(|e| e.message())?;
assert!(
validation.validation.errors.is_empty(),
"{:?}",
validation.validation
);
let backup = create_backup(&BackupCreateOptions {
workspace_path: dir.path().to_owned(),
database_path: Some(database.clone()),
output_dir: None,
label: None,
redaction_level: redaction,
include_derived: false,
include_graph_cache: false,
dry_run: false,
})
.map_err(|e| e.message())?;
let side_root = tempfile::tempdir().map_err(|e| e.to_string())?;
let side = side_root.path().join("restored");
let restored = restore_backup_to_side_path(&BackupRestoreOptions {
workspace_path: dir.path().to_owned(),
backup_path: backup.backup_path.into(),
side_path: side.clone(),
restore_graph_cache: false,
dry_run: false,
})
.map_err(|e| e.message())?;
let db = DbConnection::open_file(&restored.restored_database_path)
.map_err(|e| e.to_string())?;
let actual_workspace = db
.list_workspaces()
.map_err(|e| e.to_string())?
.remove(0)
.id;
assert_eq!(
actual_workspace, workspace_id,
"recovery preserves durable workspace identity"
);
let memories = db
.list_memories(&actual_workspace, None, false)
.map_err(|e| e.to_string())?;
assert_eq!(memories.len(), 3);
let row = db
.get_curation_candidate(&actual_workspace, &candidate.id)
.map_err(|e| e.to_string())?
.ok_or("candidate missing")?;
let creation = db
.list_audit_by_target("curation_candidate", &candidate.id, None)
.map_err(|e| e.to_string())?
.into_iter()
.find(|audit| audit.action == audit_actions::CURATION_CANDIDATE_CREATE)
.ok_or("creation audit missing")?;
let metadata: serde_json::Value =
serde_json::from_str(creation.details.as_deref().ok_or("creation details")?)
.map_err(|e| e.to_string())?;
for index in 0..3 {
assert!(
metadata["learningEvidenceIds"]
.as_array()
.ok_or("learning evidence IDs")?
.contains(&serde_json::json!(format!("fb_{index:026}"))),
"feedback provenance must survive recovery"
);
}
let sources =
crate::core::curate::audited_source_memory_ids_for_rule_candidate(&db, &row)
.map_err(|e| e.message())?;
assert_eq!(
sources.len(),
3,
"recovery must retain every source, not just the target memory"
);
db.close().map_err(|e| e.to_string())?;
let validation = validate_curation_candidate(&CurateValidateOptions {
workspace_path: &side,
database_path: None,
candidate_id: &candidate.id,
actor: Some("recovery-reviewer"),
dry_run: false,
})
.map_err(|e| e.message())?;
assert!(
validation.validation.errors.is_empty(),
"{:?}",
validation.validation
);
let options = CurateApplyOptions {
workspace_path: &side,
database_path: None,
candidate_id: &candidate.id,
actor: Some("recovery-reviewer"),
dry_run: false,
allow_tombstone_load_bearing: false,
};
let applied = apply_curation_candidate_as_recipe(
&options,
"Recovered release",
"Preparing a release",
)
.map_err(|e| e.message())?;
assert!(applied.durable_mutation, "{:?}", applied.application);
let db = DbConnection::open_file(&restored.restored_database_path)
.map_err(|e| e.to_string())?;
let recipes = db
.list_plan_recipes(&actual_workspace)
.map_err(|e| e.to_string())?;
assert_eq!(recipes.len(), 1);
let evidence: Vec<String> =
serde_json::from_str(&recipes[0].evidence_uris_json).map_err(|e| e.to_string())?;
for memory in &memories {
assert!(
evidence.contains(&format!("ee://memory/{}", memory.id)),
"{evidence:?}"
);
}
let replay = apply_curation_candidate_as_recipe(
&options,
"Recovered release",
"Preparing a release",
)
.map_err(|e| e.message())?;
assert!(!replay.durable_mutation);
assert_eq!(replay.application.status, "already_applied");
db.close().map_err(|e| e.to_string())?;
let second_backup = create_backup(&BackupCreateOptions {
workspace_path: side.clone(),
database_path: None,
output_dir: None,
label: None,
redaction_level: redaction,
include_derived: false,
include_graph_cache: false,
dry_run: false,
})
.map_err(|e| e.message())?;
let second_side = side_root.path().join("restored-again");
let second_restore = restore_backup_to_side_path(&BackupRestoreOptions {
workspace_path: side.clone(),
backup_path: second_backup.backup_path.into(),
side_path: second_side.clone(),
restore_graph_cache: false,
dry_run: false,
})
.map_err(|e| format!("second restore ({redaction:?}): {}", e.message()))?;
let db = DbConnection::open_file(&second_restore.restored_database_path)
.map_err(|e| e.to_string())?;
let recovered_recipes = db
.list_plan_recipes(&actual_workspace)
.map_err(|e| e.to_string())?;
assert_eq!(recovered_recipes.len(), 1);
assert_eq!(
recovered_recipes[0].id, recipes[0].id,
"producer-derived recipe identity survives {redaction:?} recovery"
);
let recovered_evidence: Vec<String> =
serde_json::from_str(&recovered_recipes[0].evidence_uris_json)
.map_err(|e| e.to_string())?;
for memory in db
.list_memories(&actual_workspace, None, false)
.map_err(|e| e.to_string())?
{
assert!(recovered_evidence.contains(&format!("ee://memory/{}", memory.id)));
}
assert!(
recovered_evidence.contains(&format!("ee://curation-candidate/{}", candidate.id))
);
db.close().map_err(|e| e.to_string())?;
let replay = apply_curation_candidate_as_recipe(
&CurateApplyOptions {
workspace_path: &second_side,
..options
},
"Recovered release",
"Preparing a release",
)
.map_err(|e| format!("recovered recipe replay ({redaction:?}): {}", e.message()))?;
assert_eq!(
replay.application.status, "already_applied",
"recovered applied recipe retains its identity"
);
assert!(!replay.durable_mutation);
let source = DbConnection::open_file(&database).map_err(|e| e.to_string())?;
assert_eq!(
source
.get_curation_candidate(&workspace_id, &candidate.id)
.map_err(|e| e.to_string())?
.ok_or("source candidate")?
.status,
"approved"
);
}
Ok(())
}
#[test]
fn learn_experiment_proposals_persist_deterministic_rule_candidates() -> TestResult {
let (dir, database, workspace_id) = seed_learning_workspace("ee-learn-propose")?;
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
for index in 0..50 {
let memory_id = format!("mem_{index:026}");
let feedback_id = format!("fb_{index:026}");
seed_memory(
&connection,
&workspace_id,
&memory_id,
"large_cluster",
"Use RCH with an isolated Cargo target directory before closing shared Rust beads.",
)?;
seed_feedback(
&connection,
&workspace_id,
&feedback_id,
&memory_id,
"confirmation",
)?;
}
connection.close().map_err(|error| error.to_string())?;
let options = LearnExperimentProposeOptions {
workspace: dir.path().to_path_buf(),
limit: 5,
topic: Some("large_cluster".to_string()),
min_expected_value: 0.0,
max_attention_tokens: 900,
max_runtime_seconds: 180,
safety_boundary: ExperimentSafetyBoundary::DryRunOnly,
};
let first = propose_experiments(&options).map_err(|error| error.message())?;
let second = propose_experiments(&options).map_err(|error| error.message())?;
assert_eq!(first.proposals.len(), 1);
assert_eq!(
first.proposals[0].experiment_id,
second.proposals[0].experiment_id
);
assert!(first.proposals[0].evidence_ids.len() >= 2);
assert_eq!(first.proposals[0].topic, "large_cluster");
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
let candidates = connection
.list_curation_candidates(&workspace_id, Some("rule"), Some("pending"), None)
.map_err(|error| error.to_string())?;
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].source_type, "feedback_event");
assert!(
crate::policy::validate_trust_promotion_evidence(
"agent_validated",
&candidates[0].source_type,
candidates[0].source_id.as_deref().unwrap_or_default(),
)
.is_ok()
);
assert!(candidates[0].derivation_source_refs_json.is_none());
assert!(candidates[0].derivation_metadata_json.is_none());
let audits = connection
.list_audit_by_target("curation_candidate", &candidates[0].id, None)
.map_err(|e| e.to_string())?;
assert_eq!(
audits.len(),
1,
"repeated proposal must not duplicate its creation audit"
);
assert_eq!(audits[0].action, audit_actions::CURATION_CANDIDATE_CREATE);
assert_eq!(
audits[0].this_row_hash.as_deref(),
Some(crate::db::compute_audit_row_hash(&audits[0]).as_str())
);
let metadata: serde_json::Value =
serde_json::from_str(audits[0].details.as_deref().unwrap_or_default())
.map_err(|e| e.to_string())?;
let source_refs = &metadata["sourceRefs"];
assert_eq!(source_refs.as_array().map(Vec::len), Some(50));
let evidence = metadata["learningEvidenceIds"]
.as_array()
.ok_or("learning evidence must be an array")?;
for index in 0..50 {
assert!(evidence.contains(&serde_json::json!(format!("fb_{index:026}"))));
assert_eq!(source_refs[index]["id"], format!("mem_{index:026}"));
}
let candidate_id = candidates[0].id.clone();
connection.close().map_err(|error| error.to_string())?;
let validated = crate::core::curate::validate_curation_candidate(
&crate::core::curate::CurateValidateOptions {
workspace_path: dir.path(),
database_path: Some(&database),
candidate_id: &candidate_id,
actor: Some("learning-recipe-test"),
dry_run: false,
},
)
.map_err(|e| e.message())?;
assert!(
validated.validation.errors.is_empty(),
"{:?}",
validated.validation.errors
);
let applied = crate::core::curate::apply_curation_candidate_as_recipe(
&crate::core::curate::CurateApplyOptions {
workspace_path: dir.path(),
database_path: Some(&database),
candidate_id: &candidate_id,
actor: Some("learning-recipe-test"),
dry_run: false,
allow_tombstone_load_bearing: false,
},
"Shared Rust verification",
"Before closing shared Rust work",
)
.map_err(|e| e.message())?;
assert!(applied.durable_mutation, "{:?}", applied.application);
let connection = DbConnection::open_file_read_only(&database).map_err(|e| e.to_string())?;
let recipes = connection
.list_plan_recipes(&workspace_id)
.map_err(|e| e.to_string())?;
assert_eq!(recipes.len(), 1);
assert!(recipes[0].evidence_uris_json.contains(&candidate_id));
let recipe_evidence: Vec<String> =
serde_json::from_str(&recipes[0].evidence_uris_json).map_err(|e| e.to_string())?;
assert_eq!(recipe_evidence.len(), 51);
for index in 0..50 {
assert!(recipe_evidence.contains(&format!("ee://memory/mem_{index:026}")));
}
let explained =
crate::core::plan::explain_recipe(dir.path(), Some(&database), &recipes[0].id)
.map_err(|e| e.message())?;
assert!(explained.found);
assert!(explained.steps[0].contains("Cargo target directory"));
assert_eq!(explained.maturity.as_deref(), Some("draft"));
assert_eq!(explained.evidence_uris, recipe_evidence);
connection.close().map_err(|e| e.to_string())
}
#[test]
fn learn_proposal_rolls_back_candidate_when_creation_audit_fails() -> TestResult {
let (dir, database, workspace_id) = seed_learning_workspace("ee-learn-audit-rollback")?;
let connection = DbConnection::open_file(&database).map_err(|e| e.to_string())?;
let memory_id = "mem_00000000000000000000000001";
seed_memory(
&connection,
&workspace_id,
memory_id,
"rollback_cluster",
"Review evidence before promoting a procedural rule.",
)?;
for index in 0..2 {
seed_feedback(
&connection,
&workspace_id,
&format!("fb_{index:026}"),
memory_id,
"confirmation",
)?;
}
connection.execute_raw("CREATE TRIGGER block_learning_audit BEFORE INSERT ON audit_log BEGIN SELECT RAISE(ABORT, 'learning audit failure'); END")
.map_err(|e| e.to_string())?;
let result = propose_experiments(&LearnExperimentProposeOptions {
workspace: dir.path().to_path_buf(),
limit: 5,
topic: Some("rollback_cluster".to_owned()),
min_expected_value: 0.0,
max_attention_tokens: 900,
max_runtime_seconds: 180,
safety_boundary: ExperimentSafetyBoundary::DryRunOnly,
});
let error = result.expect_err("audit failure must roll back the proposal");
assert!(
error.message().contains("learning audit failure"),
"{}",
error.message()
);
assert!(
connection
.list_curation_candidates(&workspace_id, Some("rule"), None, None)
.map_err(|e| e.to_string())?
.is_empty()
);
connection.close().map_err(|e| e.to_string())
}
#[test]
fn learn_experiment_proposals_preserve_peer_evidence_provenance() -> TestResult {
let (dir, database, workspace_id) = seed_learning_workspace("ee-learn-peer-propose")?;
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
let memory_id = "mem_00000000000000000000002001";
seed_memory(
&connection,
&workspace_id,
memory_id,
"peer_cluster",
"Run remote-cache evidence through local review before promoting peer rules.",
)?;
for (index, peer_id) in ["peer_alpha01", "peer_beta002"].into_iter().enumerate() {
connection
.insert_feedback_event(
&format!("fb_peerlearn{index:017}"),
&CreateFeedbackEventInput {
workspace_id: workspace_id.clone(),
target_type: "memory".to_string(),
target_id: memory_id.to_string(),
signal: "confirmation".to_string(),
weight: 1.0,
source_type: "agent_inference".to_string(),
source_id: Some(format!(
"peer_evidence|{peer_id}|mem_remote_peer_{index}|0.125|2026-05-01T00:0{index}:00Z|0.8"
)),
reason: Some("Cached peer evidence supported the workflow.".to_string()),
evidence_json: Some(
serde_json::json!({
"evidenceIds": [memory_id],
"peerEvidence": peer_id,
})
.to_string(),
),
session_id: None,
},
)
.map_err(|error| error.to_string())?;
}
connection.close().map_err(|error| error.to_string())?;
let report = propose_experiments(&LearnExperimentProposeOptions {
workspace: dir.path().to_path_buf(),
limit: 5,
topic: Some("peer_cluster".to_string()),
min_expected_value: 0.0,
max_attention_tokens: 900,
max_runtime_seconds: 180,
safety_boundary: ExperimentSafetyBoundary::DryRunOnly,
})
.map_err(|error| error.message())?;
assert_eq!(report.proposals.len(), 1);
assert!(
report.proposals[0]
.evidence_ids
.iter()
.any(|id| id.starts_with("peer_evidence|peer_alpha01|")),
"peer evidence refs must survive the capped sample list"
);
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
let candidates = connection
.list_curation_candidates(&workspace_id, Some("rule"), Some("pending"), None)
.map_err(|error| error.to_string())?;
assert_eq!(candidates.len(), 1);
assert_eq!(candidates[0].source_type, "agent_inference");
assert_eq!(
candidates[0].proposed_trust_class.as_deref(),
Some("agent_assertion")
);
assert!(
candidates[0]
.source_id
.as_deref()
.is_some_and(|source_id| source_id.contains("peer_evidence|peer_alpha01|"))
);
assert!(candidates[0].reason.contains("Peer-origin evidence"));
connection.close().map_err(|error| error.to_string())
}
#[test]
fn experiment_proposal_json_redacts_sensitive_evidence_ids() -> TestResult {
let raw_evidence = "/data/private/cluster.json?token=ghp_FAKEabc123def456ghi7890";
let report = LearnExperimentProposalReport {
schema: LEARN_EXPERIMENT_PROPOSAL_SCHEMA_V1.to_string(),
proposals: vec![ExperimentProposal {
experiment_id: "exp_sensitive".to_string(),
question_id: "q_sensitive".to_string(),
title: "Review sensitive evidence".to_string(),
hypothesis: "Sensitive evidence can be reviewed safely.".to_string(),
status: "proposed".to_string(),
topic: "sensitive".to_string(),
expected_value: 0.5,
uncertainty_reduction: 0.25,
confidence: 0.4,
budget: ExperimentBudget {
attention_tokens: 100,
max_runtime_seconds: 30,
dry_run_required: true,
budget_class: "small".to_string(),
},
safety: ExperimentSafetyPlan {
boundary: "dry_run_only".to_string(),
dry_run_first: true,
mutation_allowed: false,
review_required: true,
stop_conditions: Vec::new(),
denied_reasons: Vec::new(),
},
decision_impact: ExperimentDecisionImpact {
decision_id: "decision_sensitive".to_string(),
target_artifact_ids: vec![raw_evidence.to_string()],
current_decision: "retain".to_string(),
possible_change: "review".to_string(),
impact_score: 0.5,
},
evidence_ids: vec![raw_evidence.to_string()],
next_command: "ee learn experiment run exp_sensitive --dry-run --json".to_string(),
}],
total_candidates: 1,
returned: 1,
min_expected_value: 0.0,
max_attention_tokens: 100,
max_runtime_seconds: 30,
generated_at: stable_learning_generated_at(),
};
let rendered = report.to_json();
assert!(rendered.contains("[REDACTED_PATH]"));
assert!(rendered.contains("[REDACTED:"));
assert!(!rendered.contains(raw_evidence));
assert!(!rendered.contains("/data/private"));
assert!(!rendered.contains("ghp_FAKE"));
assert_eq!(
report.proposals[0].evidence_ids,
vec![raw_evidence.to_string()]
);
Ok(())
}
#[test]
fn learn_experiment_run_uses_registered_persisted_proposal() -> TestResult {
let (dir, database, workspace_id) = seed_learning_workspace("ee-learn-run")?;
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
for index in 0..4 {
let memory_id = format!("mem_{index:026}");
let feedback_id = format!("fb_{index:026}");
seed_memory(
&connection,
&workspace_id,
&memory_id,
"registered_cluster",
"Replay stored evidence before promoting a learning rule.",
)?;
seed_feedback(
&connection,
&workspace_id,
&feedback_id,
&memory_id,
"confirmation",
)?;
}
connection.close().map_err(|error| error.to_string())?;
let proposal_report = propose_experiments(&LearnExperimentProposeOptions {
workspace: dir.path().to_path_buf(),
limit: 1,
topic: Some("registered_cluster".to_string()),
min_expected_value: 0.0,
max_attention_tokens: 900,
max_runtime_seconds: 180,
safety_boundary: ExperimentSafetyBoundary::HumanReview,
})
.map_err(|error| error.message())?;
let proposal = proposal_report
.proposals
.first()
.ok_or_else(|| "expected a registered experiment proposal".to_string())?;
let run = run_experiment(&LearnExperimentRunOptions {
workspace: dir.path().to_path_buf(),
experiment_id: proposal.experiment_id.clone(),
max_attention_tokens: 600,
max_runtime_seconds: 90,
dry_run: true,
})
.map_err(|error| error.message())?;
assert_eq!(run.schema, LEARN_EXPERIMENT_RUN_SCHEMA_V1);
assert_eq!(run.status, "dry_run_ready");
assert_eq!(run.experiment_id, proposal.experiment_id);
assert_eq!(run.experiment_kind, "active_learning_replay");
assert!(run.dry_run);
assert_eq!(run.budget.requested_attention_tokens, 600);
assert!(run.budget.planned_attention_tokens <= 600);
assert!(run.safety.dry_run_first);
assert!(!run.safety.mutation_allowed);
assert_eq!(run.steps.len(), 3);
assert!(run.steps.iter().all(|step| !step.writes_storage));
assert_eq!(run.observations.len(), 1);
assert!(!run.observations[0].evidence_ids.is_empty());
assert_eq!(run.outcome_preview.status, "pending_review");
assert!(
run.next_actions
.iter()
.any(|action| action.contains("ee learn observe"))
);
let connection = DbConnection::open_file(&database).map_err(|error| error.to_string())?;
let observations = connection
.list_learning_observations(&workspace_id, None)
.map_err(|error| error.to_string())?;
assert!(
observations.is_empty(),
"dry-run experiment should not persist observations"
);
connection.close().map_err(|error| error.to_string())
}
#[test]
fn learn_experiment_run_rejects_non_dry_run_before_loading_registry() -> TestResult {
match run_experiment(&LearnExperimentRunOptions {
experiment_id: "exp_database_contract_fixture".to_string(),
dry_run: false,
..Default::default()
}) {
Err(DomainError::PolicyDenied { message, repair }) => {
assert!(message.contains("--dry-run"));
assert!(
repair
.as_deref()
.is_some_and(|repair| repair.contains("learn experiment run"))
);
Ok(())
}
Err(error) => Err(format!("expected policy denial, got {}", error.code())),
Ok(_) => Err("expected policy denial, got success".to_string()),
}
}
#[test]
fn learn_experiment_run_reports_not_found_for_unregistered_experiment() -> TestResult {
let (dir, _database, _workspace_id) = seed_learning_workspace("ee-learn-run-missing")?;
match run_experiment(&LearnExperimentRunOptions {
workspace: dir.path().to_path_buf(),
experiment_id: "exp_unknown_experiment".to_string(),
dry_run: true,
..Default::default()
}) {
Err(DomainError::NotFound {
resource,
id,
repair,
}) => {
assert_eq!(resource, "learning experiment");
assert_eq!(id, "exp_unknown_experiment");
assert!(
repair
.as_deref()
.is_some_and(|repair| repair.contains("learn experiment propose"))
);
Ok(())
}
Err(error) => Err(format!("expected not_found, got {}", error.code())),
Ok(_) => Err("expected not_found, got success".to_string()),
}
}
#[test]
fn learning_generated_at_is_not_epoch_sentinel() {
let ts = super::stable_learning_generated_at();
assert!(
!ts.starts_with("1970-01-01"),
"stable_learning_generated_at must reflect wall-clock; got `{ts}`"
);
assert!(
ts.starts_with("20") || ts.starts_with("21"),
"expected 21st-century RFC 3339 timestamp; got `{ts}`"
);
}
#[test]
fn learning_generated_at_changes_across_calls() {
let a = super::stable_learning_generated_at();
let b = super::stable_learning_generated_at();
assert!(!a.starts_with("1970"));
assert!(!b.starts_with("1970"));
assert!(
chrono::DateTime::parse_from_rfc3339(&a).is_ok(),
"a parses as RFC 3339: {a}"
);
assert!(
chrono::DateTime::parse_from_rfc3339(&b).is_ok(),
"b parses as RFC 3339: {b}"
);
}
}