Skip to main content

mempal_runtime/
knowledge_distill.rs

1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use serde::Serialize;
7
8use crate::core::{
9    anchor,
10    db::Database,
11    types::{
12        BootstrapIdentityParts, Drawer, KnowledgeStatus, KnowledgeTier, MemoryDomain, MemoryKind,
13        SourceType, TriggerHints,
14    },
15    utils::{build_bootstrap_drawer_id_from_parts, current_timestamp, knowledge_source_file},
16};
17use crate::ingest::normalize::CURRENT_NORMALIZE_VERSION;
18
19#[derive(Debug, Clone)]
20pub struct DistillRequest {
21    pub statement: String,
22    pub content: String,
23    pub tier: String,
24    pub supporting_refs: Vec<String>,
25    pub wing: String,
26    pub room: String,
27    pub domain: String,
28    pub field: String,
29    pub cwd: Option<PathBuf>,
30    pub scope_constraints: Option<String>,
31    pub counterexample_refs: Vec<String>,
32    pub teaching_refs: Vec<String>,
33    pub trigger_hints: Option<TriggerHints>,
34    pub importance: i32,
35    pub dry_run: bool,
36}
37
38#[derive(Debug, Clone, Serialize)]
39pub struct DistillOutcome {
40    pub drawer_id: String,
41    pub created: bool,
42    pub dry_run: bool,
43}
44
45#[derive(Debug, Clone)]
46pub enum DistillPlan {
47    Done(DistillOutcome),
48    Create(Box<PreparedDistill>),
49}
50
51#[derive(Debug, Clone)]
52pub struct PreparedDistill {
53    pub drawer_id: String,
54    pub content: String,
55    drawer: Drawer,
56    supporting_refs: Vec<String>,
57    counterexample_refs: Vec<String>,
58    teaching_refs: Vec<String>,
59}
60
61pub fn prepare_distill(db: &Database, request: DistillRequest) -> Result<DistillPlan> {
62    if !(0..=5).contains(&request.importance) {
63        bail!("importance must be between 0 and 5");
64    }
65    let statement = trim_required(&request.statement, "statement")?;
66    let content = trim_required(&request.content, "content")?;
67    let wing = trim_required(&request.wing, "wing")?;
68    let room = trim_required(&request.room, "room")?;
69    let field = trim_required(&request.field, "field")?;
70    let domain = parse_domain(&request.domain)?;
71    let tier = parse_distill_tier(&request.tier)?;
72    let memory_kind = MemoryKind::Knowledge;
73    let status = KnowledgeStatus::Candidate;
74    let verification_refs: &[String] = &[];
75
76    let supporting_refs = normalized_nonempty_strings(&request.supporting_refs);
77    let counterexample_refs = normalized_nonempty_strings(&request.counterexample_refs);
78    let teaching_refs = normalized_nonempty_strings(&request.teaching_refs);
79    validate_distill_refs(db, "supporting_refs", &supporting_refs)?;
80    validate_distill_refs(db, "counterexample_refs", &counterexample_refs)?;
81    validate_distill_refs(db, "teaching_refs", &teaching_refs)?;
82
83    let scope_constraints = request
84        .scope_constraints
85        .as_deref()
86        .map(str::trim)
87        .filter(|value| !value.is_empty())
88        .map(ToOwned::to_owned);
89    let trigger_hints = request.trigger_hints.and_then(normalize_trigger_hints);
90    let anchor = distill_anchor(&domain, request.cwd.as_deref())?;
91    anchor::validate_anchor_domain(&domain, &anchor.anchor_kind)
92        .map_err(|message| anyhow::anyhow!(message.to_string()))?;
93
94    let drawer_id = build_bootstrap_drawer_id_from_parts(
95        &wing,
96        Some(&room),
97        &content,
98        BootstrapIdentityParts {
99            memory_kind: &memory_kind,
100            domain: &domain,
101            field: &field,
102            anchor_kind: &anchor.anchor_kind,
103            anchor_id: &anchor.anchor_id,
104            parent_anchor_id: anchor.parent_anchor_id.as_deref(),
105            provenance: None,
106            statement: Some(&statement),
107            tier: Some(&tier),
108            status: Some(&status),
109            supporting_refs: &supporting_refs,
110            counterexample_refs: &counterexample_refs,
111            teaching_refs: &teaching_refs,
112            verification_refs,
113            scope_constraints: scope_constraints.as_deref(),
114            trigger_hints: trigger_hints.as_ref(),
115        },
116    );
117
118    if request.dry_run {
119        return Ok(DistillPlan::Done(DistillOutcome {
120            drawer_id,
121            created: false,
122            dry_run: true,
123        }));
124    }
125
126    if db
127        .drawer_exists(&drawer_id)
128        .context("failed to check existing distilled drawer")?
129    {
130        return Ok(DistillPlan::Done(DistillOutcome {
131            drawer_id,
132            created: false,
133            dry_run: false,
134        }));
135    }
136
137    let drawer = Drawer {
138        id: drawer_id.clone(),
139        content: content.clone(),
140        wing,
141        room: Some(room),
142        source_file: Some(knowledge_source_file(&domain, &field, &tier, &statement)),
143        source_type: SourceType::Manual,
144        added_at: current_timestamp(),
145        chunk_index: Some(0),
146        normalize_version: CURRENT_NORMALIZE_VERSION,
147        importance: request.importance,
148        memory_kind: MemoryKind::Knowledge,
149        domain,
150        field,
151        anchor_kind: anchor.anchor_kind,
152        anchor_id: anchor.anchor_id,
153        parent_anchor_id: anchor.parent_anchor_id,
154        provenance: None,
155        statement: Some(statement),
156        tier: Some(tier),
157        status: Some(status),
158        supporting_refs: supporting_refs.clone(),
159        counterexample_refs: counterexample_refs.clone(),
160        teaching_refs: teaching_refs.clone(),
161        verification_refs: Vec::new(),
162        scope_constraints,
163        trigger_hints,
164    };
165
166    Ok(DistillPlan::Create(Box::new(PreparedDistill {
167        drawer_id,
168        content,
169        drawer,
170        supporting_refs,
171        counterexample_refs,
172        teaching_refs,
173    })))
174}
175
176pub fn commit_distill(
177    db: &Database,
178    prepared: PreparedDistill,
179    vector: &[f32],
180) -> Result<DistillOutcome> {
181    db.insert_drawer(&prepared.drawer)
182        .context("failed to insert distilled knowledge drawer")?;
183    db.insert_vector(&prepared.drawer_id, vector)
184        .context("failed to insert distilled knowledge vector")?;
185    append_audit_entry(
186        db,
187        "knowledge_distill",
188        &serde_json::json!({
189            "drawer_id": prepared.drawer_id,
190            "statement": prepared.drawer.statement,
191            "tier": prepared.drawer.tier.as_ref().map(tier_slug),
192            "status": prepared.drawer.status.as_ref().map(status_slug),
193            "supporting_refs": prepared.supporting_refs,
194            "counterexample_refs": prepared.counterexample_refs,
195            "teaching_refs": prepared.teaching_refs,
196        }),
197    )
198    .context("failed to append audit log")?;
199
200    Ok(DistillOutcome {
201        drawer_id: prepared.drawer_id,
202        created: true,
203        dry_run: false,
204    })
205}
206
207fn trim_required(value: &str, field: &str) -> Result<String> {
208    let trimmed = value.trim();
209    if trimmed.is_empty() {
210        bail!("{field} must not be empty");
211    }
212    Ok(trimmed.to_string())
213}
214
215fn normalized_nonempty_strings(values: &[String]) -> Vec<String> {
216    values
217        .iter()
218        .filter_map(|value| {
219            let trimmed = value.trim();
220            (!trimmed.is_empty()).then(|| trimmed.to_string())
221        })
222        .collect()
223}
224
225fn normalize_trigger_hints(hints: TriggerHints) -> Option<TriggerHints> {
226    let intent_tags = normalized_nonempty_strings(&hints.intent_tags);
227    let workflow_bias = normalized_nonempty_strings(&hints.workflow_bias);
228    let tool_needs = normalized_nonempty_strings(&hints.tool_needs);
229    if intent_tags.is_empty() && workflow_bias.is_empty() && tool_needs.is_empty() {
230        return None;
231    }
232    Some(TriggerHints {
233        intent_tags,
234        workflow_bias,
235        tool_needs,
236    })
237}
238
239fn parse_domain(value: &str) -> Result<MemoryDomain> {
240    match value.trim() {
241        "project" => Ok(MemoryDomain::Project),
242        "agent" => Ok(MemoryDomain::Agent),
243        "skill" => Ok(MemoryDomain::Skill),
244        "global" => Ok(MemoryDomain::Global),
245        other => bail!("unsupported domain: {other}"),
246    }
247}
248
249fn parse_distill_tier(value: &str) -> Result<KnowledgeTier> {
250    match value.trim() {
251        "dao_ren" => Ok(KnowledgeTier::DaoRen),
252        "qi" => Ok(KnowledgeTier::Qi),
253        "dao_tian" | "shu" => bail!("distill only allows candidate dao_ren or qi"),
254        other => bail!("unsupported knowledge tier: {other}"),
255    }
256}
257
258fn distill_anchor(domain: &MemoryDomain, cwd: Option<&Path>) -> Result<anchor::DerivedAnchor> {
259    if matches!(domain, MemoryDomain::Global) {
260        return Ok(anchor::DerivedAnchor {
261            anchor_kind: crate::core::types::AnchorKind::Global,
262            anchor_id: "global://default".to_string(),
263            parent_anchor_id: None,
264        });
265    }
266    let cwd = match cwd {
267        Some(path) => path.to_path_buf(),
268        None => std::env::current_dir().context("failed to read current directory")?,
269    };
270    anchor::derive_anchor_from_cwd(Some(&cwd)).context("failed to derive distill anchor")
271}
272
273fn validate_distill_refs(db: &Database, field: &str, refs: &[String]) -> Result<()> {
274    if field == "supporting_refs" && refs.is_empty() {
275        bail!("supporting_refs must not be empty");
276    }
277    for drawer_id in refs {
278        if !drawer_id.starts_with("drawer_") {
279            bail!("{field} must contain drawer ids");
280        }
281        let drawer = db
282            .get_drawer(drawer_id)
283            .with_context(|| format!("failed to load ref drawer {drawer_id}"))?
284            .with_context(|| format!("ref drawer not found: {drawer_id}"))?;
285        if drawer.memory_kind != MemoryKind::Evidence {
286            bail!("{field} must point to evidence drawers");
287        }
288    }
289    Ok(())
290}
291
292fn append_audit_entry(db: &Database, command: &str, details: &serde_json::Value) -> Result<()> {
293    let audit_path = db
294        .path()
295        .parent()
296        .map(|parent| parent.join("audit.jsonl"))
297        .unwrap_or_else(|| PathBuf::from("audit.jsonl"));
298    let mut file = OpenOptions::new()
299        .create(true)
300        .append(true)
301        .open(&audit_path)
302        .with_context(|| format!("failed to open audit log {}", audit_path.display()))?;
303    let entry = serde_json::json!({
304        "ts": current_timestamp(),
305        "command": command,
306        "details": details,
307    });
308    writeln!(
309        file,
310        "{}",
311        serde_json::to_string(&entry).context("failed to serialize audit entry")?
312    )
313    .with_context(|| format!("failed to write audit log {}", audit_path.display()))?;
314    Ok(())
315}
316
317fn tier_slug(value: &KnowledgeTier) -> &'static str {
318    match value {
319        KnowledgeTier::Qi => "qi",
320        KnowledgeTier::Shu => "shu",
321        KnowledgeTier::DaoRen => "dao_ren",
322        KnowledgeTier::DaoTian => "dao_tian",
323    }
324}
325
326fn status_slug(value: &KnowledgeStatus) -> &'static str {
327    match value {
328        KnowledgeStatus::Candidate => "candidate",
329        KnowledgeStatus::Promoted => "promoted",
330        KnowledgeStatus::Canonical => "canonical",
331        KnowledgeStatus::Demoted => "demoted",
332        KnowledgeStatus::Retired => "retired",
333    }
334}