Skip to main content

mempal_runtime/
knowledge_card_lifecycle.rs

1use anyhow::{Context, Result, bail};
2use serde::Serialize;
3use sha2::{Digest, Sha256};
4
5use crate::core::{
6    db::Database,
7    types::{
8        KnowledgeCard, KnowledgeCardEvent, KnowledgeEventType, KnowledgeEvidenceLink,
9        KnowledgeEvidenceRole, KnowledgeStatus, KnowledgeTier, MemoryKind,
10    },
11    utils::current_timestamp,
12};
13use crate::knowledge_gate::{GateEvidenceCounts, GateRequirements};
14
15struct CardLifecycleMutation {
16    old_status: KnowledgeStatus,
17    target_status: KnowledgeStatus,
18    event_type: KnowledgeEventType,
19    reason: String,
20    actor: Option<String>,
21    new_refs: Vec<String>,
22    link_role: KnowledgeEvidenceRole,
23    metadata: serde_json::Value,
24}
25
26#[derive(Debug, Clone, Serialize)]
27pub struct KnowledgeCardGateReport {
28    pub card_id: String,
29    pub tier: String,
30    pub status: String,
31    pub target_status: String,
32    pub allowed: bool,
33    pub reasons: Vec<String>,
34    pub requirements: GateRequirements,
35    pub evidence_counts: GateEvidenceCounts,
36}
37
38#[derive(Debug, Clone)]
39pub struct PromoteCardRequest {
40    pub card_id: String,
41    pub status: String,
42    pub verification_refs: Vec<String>,
43    pub reason: String,
44    pub reviewer: Option<String>,
45    pub allow_counterexamples: bool,
46    pub enforce_gate: bool,
47}
48
49#[derive(Debug, Clone)]
50pub struct DemoteCardRequest {
51    pub card_id: String,
52    pub status: String,
53    pub evidence_refs: Vec<String>,
54    pub reason: String,
55    pub reason_type: String,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct PromoteCardOutcome {
60    pub card_id: String,
61    pub old_status: String,
62    pub new_status: String,
63    pub verification_refs: Vec<String>,
64    pub gate: Option<KnowledgeCardGateReport>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct DemoteCardOutcome {
69    pub card_id: String,
70    pub old_status: String,
71    pub new_status: String,
72    pub counterexample_refs: Vec<String>,
73}
74
75pub fn evaluate_card_gate_by_id(
76    db: &Database,
77    card_id: &str,
78    target_status: Option<&str>,
79    reviewer: Option<&str>,
80    allow_counterexamples: bool,
81) -> Result<KnowledgeCardGateReport> {
82    let card = load_card(db, card_id)?;
83    let target_status = match target_status {
84        Some(value) => parse_status(value)?,
85        None => default_target_status(&card.tier),
86    };
87    validate_tier_status(&card.tier, &target_status)?;
88    let counts = evidence_counts(db, card_id)?;
89    evaluate_card_gate(
90        &card,
91        &target_status,
92        reviewer,
93        allow_counterexamples,
94        counts,
95    )
96}
97
98pub fn promote_card(db: &Database, request: PromoteCardRequest) -> Result<PromoteCardOutcome> {
99    let target_status = parse_status(&request.status)?;
100    if !matches!(
101        target_status,
102        KnowledgeStatus::Promoted | KnowledgeStatus::Canonical
103    ) {
104        bail!("promote status must be promoted or canonical");
105    }
106    validate_evidence_refs(db, &request.verification_refs)?;
107    let mut card = load_card(db, &request.card_id)?;
108    validate_tier_status(&card.tier, &target_status)?;
109    let old_status = card.status.clone();
110
111    let existing_links = db
112        .knowledge_evidence_links(&request.card_id)
113        .context("failed to list knowledge card evidence links")?;
114    let new_refs = missing_refs_for_role(
115        &existing_links,
116        &request.verification_refs,
117        &KnowledgeEvidenceRole::Verification,
118    );
119    let projected_counts = projected_counts(
120        counts_from_links(&existing_links),
121        &new_refs,
122        &KnowledgeEvidenceRole::Verification,
123    );
124    let gate = if request.enforce_gate {
125        let gate = evaluate_card_gate(
126            &card,
127            &target_status,
128            request.reviewer.as_deref(),
129            request.allow_counterexamples,
130            projected_counts,
131        )?;
132        if !gate.allowed {
133            bail!("promotion gate failed: {}", gate.reasons.join("; "));
134        }
135        Some(gate)
136    } else {
137        None
138    };
139
140    card.status = target_status.clone();
141    card.updated_at = current_timestamp();
142    apply_card_lifecycle(
143        db,
144        &card,
145        CardLifecycleMutation {
146            old_status: old_status.clone(),
147            target_status: target_status.clone(),
148            event_type: KnowledgeEventType::Promoted,
149            reason: request.reason.clone(),
150            actor: request.reviewer.clone(),
151            new_refs,
152            link_role: KnowledgeEvidenceRole::Verification,
153            metadata: serde_json::json!({
154                "verification_refs": request.verification_refs,
155                "gate_enforced": request.enforce_gate,
156            }),
157        },
158    )?;
159
160    Ok(PromoteCardOutcome {
161        card_id: request.card_id,
162        old_status: status_slug(&old_status).to_string(),
163        new_status: status_slug(&target_status).to_string(),
164        verification_refs: refs_for_role(
165            &db.knowledge_evidence_links(&card.id)
166                .context("failed to list knowledge card evidence links")?,
167            &KnowledgeEvidenceRole::Verification,
168        ),
169        gate,
170    })
171}
172
173pub fn demote_card(db: &Database, request: DemoteCardRequest) -> Result<DemoteCardOutcome> {
174    let target_status = parse_status(&request.status)?;
175    if !matches!(
176        target_status,
177        KnowledgeStatus::Demoted | KnowledgeStatus::Retired
178    ) {
179        bail!("demote status must be demoted or retired");
180    }
181    validate_demote_reason_type(&request.reason_type)?;
182    validate_evidence_refs(db, &request.evidence_refs)?;
183    let mut card = load_card(db, &request.card_id)?;
184    validate_tier_status(&card.tier, &target_status)?;
185    let old_status = card.status.clone();
186    let existing_links = db
187        .knowledge_evidence_links(&request.card_id)
188        .context("failed to list knowledge card evidence links")?;
189    let new_refs = missing_refs_for_role(
190        &existing_links,
191        &request.evidence_refs,
192        &KnowledgeEvidenceRole::Counterexample,
193    );
194
195    card.status = target_status.clone();
196    card.updated_at = current_timestamp();
197    let event_type = if matches!(target_status, KnowledgeStatus::Retired) {
198        KnowledgeEventType::Retired
199    } else {
200        KnowledgeEventType::Demoted
201    };
202    apply_card_lifecycle(
203        db,
204        &card,
205        CardLifecycleMutation {
206            old_status: old_status.clone(),
207            target_status: target_status.clone(),
208            event_type,
209            reason: request.reason.clone(),
210            actor: None,
211            new_refs,
212            link_role: KnowledgeEvidenceRole::Counterexample,
213            metadata: serde_json::json!({
214                "evidence_refs": request.evidence_refs,
215                "reason_type": request.reason_type,
216            }),
217        },
218    )?;
219
220    Ok(DemoteCardOutcome {
221        card_id: request.card_id,
222        old_status: status_slug(&old_status).to_string(),
223        new_status: status_slug(&target_status).to_string(),
224        counterexample_refs: refs_for_role(
225            &db.knowledge_evidence_links(&card.id)
226                .context("failed to list knowledge card evidence links")?,
227            &KnowledgeEvidenceRole::Counterexample,
228        ),
229    })
230}
231
232fn load_card(db: &Database, card_id: &str) -> Result<KnowledgeCard> {
233    db.get_knowledge_card(card_id)
234        .context("failed to look up knowledge card")?
235        .with_context(|| format!("knowledge card not found: {card_id}"))
236}
237
238fn evaluate_card_gate(
239    card: &KnowledgeCard,
240    target_status: &KnowledgeStatus,
241    reviewer: Option<&str>,
242    allow_counterexamples: bool,
243    evidence_counts: GateEvidenceCounts,
244) -> Result<KnowledgeCardGateReport> {
245    validate_tier_status(&card.tier, target_status)?;
246    let requirements = gate_requirements(&card.tier, target_status);
247    let mut reasons = Vec::new();
248    if evidence_counts.supporting < requirements.min_supporting_refs {
249        reasons.push(format!(
250            "supporting evidence refs below requirement: have {}, need {}",
251            evidence_counts.supporting, requirements.min_supporting_refs
252        ));
253    }
254    if evidence_counts.verification < requirements.min_verification_refs {
255        reasons.push(format!(
256            "verification evidence refs below requirement: have {}, need {}",
257            evidence_counts.verification, requirements.min_verification_refs
258        ));
259    }
260    if evidence_counts.teaching < requirements.min_teaching_refs {
261        reasons.push(format!(
262            "teaching evidence refs below requirement: have {}, need {}",
263            evidence_counts.teaching, requirements.min_teaching_refs
264        ));
265    }
266    if requirements.reviewer_required
267        && reviewer
268            .map(str::trim)
269            .filter(|value| !value.is_empty())
270            .is_none()
271    {
272        reasons.push("reviewer is required for this gate".to_string());
273    }
274    if requirements.counterexamples_block
275        && evidence_counts.counterexample > 0
276        && !allow_counterexamples
277    {
278        reasons.push(format!(
279            "counterexample refs present: {}",
280            evidence_counts.counterexample
281        ));
282    }
283
284    Ok(KnowledgeCardGateReport {
285        card_id: card.id.clone(),
286        tier: tier_slug(&card.tier).to_string(),
287        status: status_slug(&card.status).to_string(),
288        target_status: status_slug(target_status).to_string(),
289        allowed: reasons.is_empty(),
290        reasons,
291        requirements,
292        evidence_counts,
293    })
294}
295
296fn apply_card_lifecycle(
297    db: &Database,
298    card: &KnowledgeCard,
299    mutation: CardLifecycleMutation,
300) -> Result<()> {
301    db.conn().execute_batch("BEGIN IMMEDIATE TRANSACTION")?;
302    let result = (|| {
303        for evidence_drawer_id in &mutation.new_refs {
304            let link = KnowledgeEvidenceLink {
305                id: deterministic_link_id(&card.id, evidence_drawer_id, &mutation.link_role),
306                card_id: card.id.clone(),
307                evidence_drawer_id: evidence_drawer_id.clone(),
308                role: mutation.link_role.clone(),
309                note: Some(format!("linked during card lifecycle: {}", mutation.reason)),
310                created_at: current_timestamp(),
311            };
312            db.insert_knowledge_evidence_link(&link)
313                .context("failed to insert knowledge card evidence link")?;
314        }
315        db.update_knowledge_card(card)
316            .context("failed to update knowledge card")?;
317        let event = KnowledgeCardEvent {
318            id: deterministic_event_id(&card.id, &mutation.event_type, &mutation.reason),
319            card_id: card.id.clone(),
320            event_type: mutation.event_type,
321            from_status: Some(mutation.old_status),
322            to_status: Some(mutation.target_status),
323            reason: mutation.reason,
324            actor: mutation.actor,
325            metadata: Some(mutation.metadata),
326            created_at: current_timestamp(),
327        };
328        db.append_knowledge_event(&event)
329            .context("failed to append knowledge card event")?;
330        Ok(())
331    })();
332
333    match result {
334        Ok(()) => {
335            db.conn().execute_batch("COMMIT")?;
336            Ok(())
337        }
338        Err(error) => {
339            let _ = db.conn().execute_batch("ROLLBACK");
340            Err(error)
341        }
342    }
343}
344
345fn evidence_counts(db: &Database, card_id: &str) -> Result<GateEvidenceCounts> {
346    let links = db
347        .knowledge_evidence_links(card_id)
348        .context("failed to list knowledge card evidence links")?;
349    Ok(counts_from_links(&links))
350}
351
352fn counts_from_links(links: &[KnowledgeEvidenceLink]) -> GateEvidenceCounts {
353    GateEvidenceCounts {
354        supporting: links
355            .iter()
356            .filter(|link| matches!(link.role, KnowledgeEvidenceRole::Supporting))
357            .count(),
358        counterexample: links
359            .iter()
360            .filter(|link| matches!(link.role, KnowledgeEvidenceRole::Counterexample))
361            .count(),
362        teaching: links
363            .iter()
364            .filter(|link| matches!(link.role, KnowledgeEvidenceRole::Teaching))
365            .count(),
366        verification: links
367            .iter()
368            .filter(|link| matches!(link.role, KnowledgeEvidenceRole::Verification))
369            .count(),
370    }
371}
372
373fn projected_counts(
374    mut counts: GateEvidenceCounts,
375    new_refs: &[String],
376    role: &KnowledgeEvidenceRole,
377) -> GateEvidenceCounts {
378    match role {
379        KnowledgeEvidenceRole::Supporting => counts.supporting += new_refs.len(),
380        KnowledgeEvidenceRole::Verification => counts.verification += new_refs.len(),
381        KnowledgeEvidenceRole::Counterexample => counts.counterexample += new_refs.len(),
382        KnowledgeEvidenceRole::Teaching => counts.teaching += new_refs.len(),
383    }
384    counts
385}
386
387fn missing_refs_for_role(
388    existing_links: &[KnowledgeEvidenceLink],
389    refs: &[String],
390    role: &KnowledgeEvidenceRole,
391) -> Vec<String> {
392    refs.iter()
393        .filter(|drawer_id| {
394            !existing_links
395                .iter()
396                .any(|link| link.role == *role && link.evidence_drawer_id == **drawer_id)
397        })
398        .cloned()
399        .collect()
400}
401
402fn refs_for_role(links: &[KnowledgeEvidenceLink], role: &KnowledgeEvidenceRole) -> Vec<String> {
403    links
404        .iter()
405        .filter(|link| link.role == *role)
406        .map(|link| link.evidence_drawer_id.clone())
407        .collect()
408}
409
410fn validate_evidence_refs(db: &Database, refs: &[String]) -> Result<()> {
411    if refs.is_empty() {
412        bail!("at least one evidence ref is required");
413    }
414    for drawer_id in refs {
415        if !drawer_id.starts_with("drawer_") {
416            bail!("evidence refs must contain drawer ids");
417        }
418        let drawer = db
419            .get_drawer(drawer_id)
420            .with_context(|| format!("failed to load evidence drawer {drawer_id}"))?
421            .with_context(|| format!("evidence drawer not found: {drawer_id}"))?;
422        if drawer.memory_kind != MemoryKind::Evidence {
423            bail!("evidence refs must point to evidence drawers");
424        }
425    }
426    Ok(())
427}
428
429fn parse_status(value: &str) -> Result<KnowledgeStatus> {
430    match value.trim() {
431        "candidate" => Ok(KnowledgeStatus::Candidate),
432        "promoted" => Ok(KnowledgeStatus::Promoted),
433        "canonical" => Ok(KnowledgeStatus::Canonical),
434        "demoted" => Ok(KnowledgeStatus::Demoted),
435        "retired" => Ok(KnowledgeStatus::Retired),
436        other => bail!("unsupported knowledge status: {other}"),
437    }
438}
439
440fn default_target_status(tier: &KnowledgeTier) -> KnowledgeStatus {
441    match tier {
442        KnowledgeTier::DaoTian => KnowledgeStatus::Canonical,
443        KnowledgeTier::DaoRen | KnowledgeTier::Shu | KnowledgeTier::Qi => KnowledgeStatus::Promoted,
444    }
445}
446
447fn validate_tier_status(tier: &KnowledgeTier, status: &KnowledgeStatus) -> Result<()> {
448    let allowed = match tier {
449        KnowledgeTier::DaoTian => &[KnowledgeStatus::Canonical, KnowledgeStatus::Demoted][..],
450        KnowledgeTier::DaoRen => &[
451            KnowledgeStatus::Candidate,
452            KnowledgeStatus::Promoted,
453            KnowledgeStatus::Demoted,
454            KnowledgeStatus::Retired,
455        ][..],
456        KnowledgeTier::Shu => &[
457            KnowledgeStatus::Promoted,
458            KnowledgeStatus::Demoted,
459            KnowledgeStatus::Retired,
460        ][..],
461        KnowledgeTier::Qi => &[
462            KnowledgeStatus::Candidate,
463            KnowledgeStatus::Promoted,
464            KnowledgeStatus::Demoted,
465            KnowledgeStatus::Retired,
466        ][..],
467    };
468
469    if allowed.contains(status) {
470        return Ok(());
471    }
472
473    match tier {
474        KnowledgeTier::DaoTian => bail!("dao_tian only allows canonical or demoted"),
475        KnowledgeTier::DaoRen => {
476            bail!("dao_ren only allows candidate, promoted, demoted, or retired")
477        }
478        KnowledgeTier::Shu => bail!("shu only allows promoted, demoted, or retired"),
479        KnowledgeTier::Qi => bail!("qi only allows candidate, promoted, demoted, or retired"),
480    }
481}
482
483fn validate_demote_reason_type(value: &str) -> Result<()> {
484    match value.trim() {
485        "contradicted" | "obsolete" | "superseded" | "out_of_scope" | "unsafe" => Ok(()),
486        other => bail!("unsupported demote reason_type: {other}"),
487    }
488}
489
490fn gate_requirements(tier: &KnowledgeTier, target_status: &KnowledgeStatus) -> GateRequirements {
491    match (tier, target_status) {
492        (KnowledgeTier::DaoTian, KnowledgeStatus::Canonical) => GateRequirements {
493            min_supporting_refs: 3,
494            min_verification_refs: 2,
495            min_teaching_refs: 1,
496            reviewer_required: true,
497            counterexamples_block: true,
498        },
499        (KnowledgeTier::DaoRen, KnowledgeStatus::Promoted) => GateRequirements {
500            min_supporting_refs: 2,
501            min_verification_refs: 1,
502            min_teaching_refs: 0,
503            reviewer_required: false,
504            counterexamples_block: true,
505        },
506        (KnowledgeTier::Shu | KnowledgeTier::Qi, KnowledgeStatus::Promoted) => GateRequirements {
507            min_supporting_refs: 1,
508            min_verification_refs: 1,
509            min_teaching_refs: 0,
510            reviewer_required: false,
511            counterexamples_block: true,
512        },
513        _ => GateRequirements {
514            min_supporting_refs: 0,
515            min_verification_refs: 0,
516            min_teaching_refs: 0,
517            reviewer_required: false,
518            counterexamples_block: true,
519        },
520    }
521}
522
523fn deterministic_link_id(
524    card_id: &str,
525    evidence_drawer_id: &str,
526    role: &KnowledgeEvidenceRole,
527) -> String {
528    let mut hasher = Sha256::new();
529    hasher.update(b"knowledge-card-lifecycle-link-v1");
530    hasher.update([0]);
531    hasher.update(card_id.as_bytes());
532    hasher.update([0]);
533    hasher.update(evidence_drawer_id.as_bytes());
534    hasher.update([0]);
535    hasher.update(evidence_role_slug(role).as_bytes());
536    let digest = format!("{:x}", hasher.finalize());
537    format!("link_{}", &digest[..16])
538}
539
540fn deterministic_event_id(card_id: &str, event_type: &KnowledgeEventType, reason: &str) -> String {
541    let mut hasher = Sha256::new();
542    hasher.update(b"knowledge-card-lifecycle-event-v1");
543    hasher.update([0]);
544    hasher.update(card_id.as_bytes());
545    hasher.update([0]);
546    hasher.update(event_type_slug(event_type).as_bytes());
547    hasher.update([0]);
548    hasher.update(reason.as_bytes());
549    let digest = format!("{:x}", hasher.finalize());
550    format!("event_{}", &digest[..16])
551}
552
553fn evidence_role_slug(value: &KnowledgeEvidenceRole) -> &'static str {
554    match value {
555        KnowledgeEvidenceRole::Supporting => "supporting",
556        KnowledgeEvidenceRole::Verification => "verification",
557        KnowledgeEvidenceRole::Counterexample => "counterexample",
558        KnowledgeEvidenceRole::Teaching => "teaching",
559    }
560}
561
562fn event_type_slug(value: &KnowledgeEventType) -> &'static str {
563    match value {
564        KnowledgeEventType::Created => "created",
565        KnowledgeEventType::Promoted => "promoted",
566        KnowledgeEventType::Demoted => "demoted",
567        KnowledgeEventType::Retired => "retired",
568        KnowledgeEventType::Linked => "linked",
569        KnowledgeEventType::Unlinked => "unlinked",
570        KnowledgeEventType::Updated => "updated",
571        KnowledgeEventType::PublishedAnchor => "published_anchor",
572    }
573}
574
575fn tier_slug(value: &KnowledgeTier) -> &'static str {
576    match value {
577        KnowledgeTier::Qi => "qi",
578        KnowledgeTier::Shu => "shu",
579        KnowledgeTier::DaoRen => "dao_ren",
580        KnowledgeTier::DaoTian => "dao_tian",
581    }
582}
583
584fn status_slug(value: &KnowledgeStatus) -> &'static str {
585    match value {
586        KnowledgeStatus::Candidate => "candidate",
587        KnowledgeStatus::Promoted => "promoted",
588        KnowledgeStatus::Canonical => "canonical",
589        KnowledgeStatus::Demoted => "demoted",
590        KnowledgeStatus::Retired => "retired",
591    }
592}