asurada 0.3.1

Asurada — a memory + cognition daemon that grows with the user. Local-first, BYOK, shared by Devist/Webchemist Core/etc.
// 코드로 강제되는 정책 — LLM 응답이 어떻든 깨질 수 없는 규칙들.
//
// 모든 자동 변경 작업 (consolidation / reflection / archive 등) 은
// 진행 전 이 모듈의 ensure_* 함수를 호출해 정책 위반 여부를 확인한다.
//
// prompts/policies.md 가 LLM 가드라면, 이 파일은 그 위에 올라가는
// 결정론적 안전망이다. LLM 이 "삭제하라" 해도 ensure_can_delete() 가
// 거부하면 실행 안 된다.

#![allow(dead_code)]

use crate::db::memory::Memory;
use chrono::{DateTime, Duration, Utc};
use thiserror::Error;

// ───── 임계값 ──────────────────────────────────────────────

/// Auto-apply 가능 최소 confidence (0.0 ~ 1.0).
/// 이 미만이면 사용자 명시 승인 필요.
pub const MIN_CONFIDENCE_TO_AUTO_APPLY: f64 = 0.7;

/// 같은 메모리 패턴이 N 회 reject 되면 deprioritize.
pub const REJECT_COUNT_TO_DEPRIORITIZE: u32 = 3;

/// 같은 advice 가 N 회 무시되면 강한 학습 후보로 사용자에게 제안.
pub const IGNORE_COUNT_TO_SUGGEST_STRONG: u32 = 5;

/// info 메모리 archive 후보가 되기 위한 비활성 일수.
pub const ARCHIVE_INACTIVITY_DAYS: i64 = 90;

/// Consolidation 한 번에 변경 가능한 메모리 최대 수.
pub const MAX_CONSOLIDATION_BATCH_SIZE: usize = 10;

/// 시간당 Claude 호출 최대 횟수.
pub const MAX_CLAUDE_CALLS_PER_HOUR: u32 = 60;

/// 시간당 메모리 쓰기 최대 횟수.
pub const MAX_MEMORY_WRITES_PER_HOUR: u32 = 100;

// ───── 정책 위반 종류 ──────────────────────────────────────

#[derive(Debug, Error)]
pub enum PolicyError {
    #[error("user-authored memory cannot be auto-modified or deleted")]
    UserAuthoredProtected,

    #[error("constraint-priority memory cannot be auto-changed")]
    ConstraintProtected,

    #[error("archive only allowed for priority='info'")]
    NotInfoPriority,

    #[error("memory was used recently — cannot archive yet")]
    RecentlyUsed,

    #[error("no semantically-better alternative — archive blocked")]
    NoBetterAlternative,

    #[error("confidence {got:.2} below threshold {min:.2}")]
    LowConfidence { got: f64, min: f64 },

    #[error("batch size {got} exceeds max {max}")]
    BatchSizeExceeded { got: usize, max: usize },

    #[error("rate limit: {scope} ({rate}/hour)")]
    RateLimitExceeded { scope: &'static str, rate: u32 },

    #[error("hard delete forbidden — use soft delete (status='deleted')")]
    HardDeleteForbidden,
}

// ───── 정책 강제 함수 ──────────────────────────────────────

/// 메모리를 자동으로 삭제(soft) 할 수 있는가.
/// `source='user'` 와 `priority='constraint'` 는 절대 자동 삭제 불가.
pub fn ensure_can_auto_delete(memory: &Memory) -> Result<(), PolicyError> {
    if memory.source == "user" {
        return Err(PolicyError::UserAuthoredProtected);
    }
    if memory.priority == "constraint" {
        return Err(PolicyError::ConstraintProtected);
    }
    Ok(())
}

/// 메모리 텍스트를 자동으로 수정할 수 있는가.
/// user-authored 는 텍스트 보존 — 메타데이터(updated_at 등)만 변경 가능.
pub fn ensure_can_auto_modify_text(memory: &Memory) -> Result<(), PolicyError> {
    if memory.source == "user" {
        return Err(PolicyError::UserAuthoredProtected);
    }
    Ok(())
}

/// 메모리를 archive 처리할 수 있는가.
/// 조건: priority='info', source!='user', 비활성 + 더 나은 대체 존재.
pub fn ensure_can_archive(
    memory: &Memory,
    last_used: Option<DateTime<Utc>>,
    has_better_alternative: bool,
) -> Result<(), PolicyError> {
    ensure_can_auto_delete(memory)?; // user/constraint 보호
    if memory.priority != "info" {
        return Err(PolicyError::NotInfoPriority);
    }
    if !has_better_alternative {
        return Err(PolicyError::NoBetterAlternative);
    }
    if let Some(t) = last_used {
        let cutoff = Utc::now() - Duration::days(ARCHIVE_INACTIVITY_DAYS);
        if t > cutoff {
            return Err(PolicyError::RecentlyUsed);
        }
    }
    Ok(())
}

/// Confidence 가 auto-apply 임계값 이상인가.
pub fn ensure_confidence_for_auto_apply(confidence: f64) -> Result<(), PolicyError> {
    if confidence < MIN_CONFIDENCE_TO_AUTO_APPLY {
        return Err(PolicyError::LowConfidence {
            got: confidence,
            min: MIN_CONFIDENCE_TO_AUTO_APPLY,
        });
    }
    Ok(())
}

/// Consolidation batch 크기 제한.
pub fn ensure_batch_size(batch: usize) -> Result<(), PolicyError> {
    if batch > MAX_CONSOLIDATION_BATCH_SIZE {
        return Err(PolicyError::BatchSizeExceeded {
            got: batch,
            max: MAX_CONSOLIDATION_BATCH_SIZE,
        });
    }
    Ok(())
}

// ───── 사용자 신호 휴리스틱 ────────────────────────────────

/// reject 카운트가 deprioritize 임계 도달했는가 (정책 A).
pub fn should_deprioritize_pattern(reject_count: u32) -> bool {
    reject_count >= REJECT_COUNT_TO_DEPRIORITIZE
}

/// 무시 카운트가 강한 학습 제안 임계 도달했는가 (정책 E).
pub fn should_propose_as_strong(ignore_count: u32) -> bool {
    ignore_count >= IGNORE_COUNT_TO_SUGGEST_STRONG
}

// ───── 사용자 격리 (G4) ────────────────────────────────────

/// DB 레이어가 항상 user_id 를 받게 강제됨 (memory.rs 참조).
/// 이 함수는 그 사실을 코드 리뷰 시 명시하기 위한 marker.
///
/// 모든 검색 / 조회 / 변경 SQL 에 user_id 필터가 들어가야 함.
pub const USER_ISOLATION_NOTE: &str =
    "All queries MUST filter by user_id. See db/memory.rs for enforcement.";

// ───── 프로젝트 격리 (B) ───────────────────────────────────

/// 메모리가 advice 생성 시 끌어와도 되는가.
/// scope='project' 메모리는 같은 프로젝트에만 끌어옴.
pub fn can_inject_into_advice(memory: &Memory, current_project: &str) -> bool {
    if memory.status != "active" {
        return false;
    }
    match memory.scope.as_str() {
        "project" => memory.project.as_deref() == Some(current_project),
        "user" | "tech" => true,
        _ => false,
    }
}

// ───── 단위 테스트 ─────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    fn make_memory(source: &str, priority: &str, scope: &str, project: Option<&str>) -> Memory {
        Memory {
            id: "test".into(),
            user_id: "alice".into(),
            text: "x".into(),
            scope: scope.into(),
            priority: priority.into(),
            source: source.into(),
            project: project.map(String::from),
            tech: None,
            metadata: serde_json::json!({}),
            status: "active".into(),
            created_at: "now".into(),
            updated_at: "now".into(),
            deleted_at: None,
        }
    }

    #[test]
    fn user_authored_cannot_be_deleted() {
        let m = make_memory("user", "info", "user", None);
        assert!(matches!(
            ensure_can_auto_delete(&m),
            Err(PolicyError::UserAuthoredProtected)
        ));
    }

    #[test]
    fn constraint_cannot_be_deleted() {
        let m = make_memory("asurada", "constraint", "user", None);
        assert!(matches!(
            ensure_can_auto_delete(&m),
            Err(PolicyError::ConstraintProtected)
        ));
    }

    #[test]
    fn asurada_info_can_be_deleted() {
        let m = make_memory("asurada", "info", "user", None);
        assert!(ensure_can_auto_delete(&m).is_ok());
    }

    #[test]
    fn archive_blocked_when_recently_used() {
        let m = make_memory("asurada", "info", "user", None);
        let recent = Utc::now() - Duration::days(10);
        assert!(matches!(
            ensure_can_archive(&m, Some(recent), true),
            Err(PolicyError::RecentlyUsed)
        ));
    }

    #[test]
    fn archive_allowed_when_old_and_better_alt() {
        let m = make_memory("asurada", "info", "user", None);
        let old = Utc::now() - Duration::days(120);
        assert!(ensure_can_archive(&m, Some(old), true).is_ok());
    }

    #[test]
    fn archive_blocked_for_non_info() {
        let m = make_memory("asurada", "strong", "user", None);
        let old = Utc::now() - Duration::days(120);
        assert!(matches!(
            ensure_can_archive(&m, Some(old), true),
            Err(PolicyError::NotInfoPriority)
        ));
    }

    #[test]
    fn archive_blocked_without_better_alternative() {
        let m = make_memory("asurada", "info", "user", None);
        let old = Utc::now() - Duration::days(120);
        assert!(matches!(
            ensure_can_archive(&m, Some(old), false),
            Err(PolicyError::NoBetterAlternative)
        ));
    }

    #[test]
    fn project_scope_isolates_to_same_project() {
        let m = make_memory("asurada", "strong", "project", Some("Devist"));
        assert!(can_inject_into_advice(&m, "Devist"));
        assert!(!can_inject_into_advice(&m, "WC-Core"));
    }

    #[test]
    fn user_scope_crosses_projects() {
        let m = make_memory("asurada", "strong", "user", None);
        assert!(can_inject_into_advice(&m, "Devist"));
        assert!(can_inject_into_advice(&m, "WC-Core"));
    }

    #[test]
    fn confidence_threshold() {
        assert!(ensure_confidence_for_auto_apply(0.9).is_ok());
        assert!(matches!(
            ensure_confidence_for_auto_apply(0.5),
            Err(PolicyError::LowConfidence { .. })
        ));
    }

    #[test]
    fn batch_size_limit() {
        assert!(ensure_batch_size(MAX_CONSOLIDATION_BATCH_SIZE).is_ok());
        assert!(matches!(
            ensure_batch_size(MAX_CONSOLIDATION_BATCH_SIZE + 1),
            Err(PolicyError::BatchSizeExceeded { .. })
        ));
    }

    #[test]
    fn deprioritize_threshold() {
        assert!(!should_deprioritize_pattern(
            REJECT_COUNT_TO_DEPRIORITIZE - 1
        ));
        assert!(should_deprioritize_pattern(REJECT_COUNT_TO_DEPRIORITIZE));
    }
}