sim-lib-agent 0.1.1

Agent runtime surfaces for SIM.
Documentation
use sim_codec_binary::{decode_frame, encode_frame};
use sim_kernel::{Cx, Error, Expr, Result, Value};
use std::{
    collections::HashMap,
    fs::{self, File, OpenOptions},
    io::{Read, Write},
    path::Path,
    sync::{Arc, Mutex, OnceLock},
};

pub(crate) type SharedEntries = Arc<Mutex<Vec<Expr>>>;
type BlackboardRegistry = Mutex<HashMap<String, SharedEntries>>;

pub(crate) fn shared_blackboard_entries(board: &str) -> SharedEntries {
    static BLACKBOARDS: OnceLock<BlackboardRegistry> = OnceLock::new();
    let boards = BLACKBOARDS.get_or_init(|| Mutex::new(HashMap::new()));
    let mut boards = boards
        .lock()
        .map_err(|_| Error::PoisonedLock("blackboard registry"))
        .unwrap();
    boards
        .entry(board.to_owned())
        .or_insert_with(|| Arc::new(Mutex::new(Vec::new())))
        .clone()
}

pub(crate) fn memory_entries_append(entries: &SharedEntries, expr: Expr) -> Result<()> {
    lock_entries(entries, "memory entries")?.push(expr);
    Ok(())
}

pub(crate) fn memory_entries_recent(
    cx: &mut Cx,
    entries: &SharedEntries,
    count: u32,
) -> Result<Vec<Value>> {
    let entries = lock_entries(entries, "memory entries")?;
    let count = usize::try_from(count).unwrap_or(usize::MAX);
    entries[entries.len().saturating_sub(count)..]
        .iter()
        .map(|expr| crate::expr_to_value(cx, expr))
        .collect()
}

pub(crate) fn memory_entries_search(
    cx: &mut Cx,
    entries: &SharedEntries,
    query: Expr,
    k: u32,
) -> Result<Vec<Value>> {
    let terms = query_terms(&query);
    let entries = lock_entries(entries, "memory entries")?;
    let filtered = entries
        .iter()
        .rev()
        .filter(|expr| memory_entry_matches(expr, &terms))
        .take(usize::try_from(k).unwrap_or(usize::MAX))
        .cloned()
        .collect::<Vec<_>>();
    filtered
        .into_iter()
        .rev()
        .map(|expr| crate::expr_to_value(cx, &expr))
        .collect()
}

pub(crate) fn memory_entries_snapshot(entries: &SharedEntries) -> Result<Expr> {
    Ok(Expr::List(lock_entries(entries, "memory entries")?.clone()))
}

pub(crate) fn memory_entries_restore(entries: &SharedEntries, snap: Expr) -> Result<()> {
    replace_memory_entries(entries, snapshot_entries(snap)?)
}

pub(crate) fn replace_memory_entries(
    entries: &SharedEntries,
    replacement: Vec<Expr>,
) -> Result<()> {
    *lock_entries(entries, "memory entries")? = replacement;
    Ok(())
}

pub(crate) fn snapshot_entries(snap: Expr) -> Result<Vec<Expr>> {
    match snap {
        Expr::Nil => Ok(Vec::new()),
        Expr::List(items) | Expr::Vector(items) => Ok(items),
        _ => Err(Error::TypeMismatch {
            expected: "memory snapshot list",
            found: "non-list",
        }),
    }
}

pub(crate) fn value_to_snapshot_expr(cx: &mut Cx, value: Value) -> Result<Expr> {
    value.object().as_expr(cx)
}

fn query_terms(expr: &Expr) -> Vec<String> {
    if let Expr::List(items) | Expr::Vector(items) = expr
        && let [Expr::Symbol(symbol), payload] = items.as_slice()
        && symbol.name.as_ref() == "query"
    {
        return query_terms(payload);
    }
    let mut out = Vec::new();
    collect_query_terms(expr, &mut out);
    out.retain(|term| !term.is_empty());
    out
}

fn collect_query_terms(expr: &Expr, out: &mut Vec<String>) {
    match expr {
        Expr::String(text) => out.extend(text.split_whitespace().map(|term| term.to_lowercase())),
        Expr::Symbol(symbol) => out.push(symbol.to_string().to_lowercase()),
        Expr::Local(symbol) => out.push(symbol.to_string().to_lowercase()),
        Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
            for item in items {
                collect_query_terms(item, out);
            }
        }
        Expr::Map(entries) => {
            for (key, value) in entries {
                collect_query_terms(key, out);
                collect_query_terms(value, out);
            }
        }
        Expr::Number(number) => out.push(number.canonical.to_lowercase()),
        Expr::Bool(value) => out.push(value.to_string()),
        Expr::Bytes(bytes) => out.push(format!("{bytes:?}").to_lowercase()),
        Expr::Nil => {}
        Expr::Call { operator, args } => {
            collect_query_terms(operator, out);
            for arg in args {
                collect_query_terms(arg, out);
            }
        }
        Expr::Infix {
            operator,
            left,
            right,
        } => {
            out.push(operator.to_string().to_lowercase());
            collect_query_terms(left, out);
            collect_query_terms(right, out);
        }
        Expr::Prefix { operator, arg } | Expr::Postfix { operator, arg } => {
            out.push(operator.to_string().to_lowercase());
            collect_query_terms(arg, out);
        }
        Expr::Quote { expr, .. } => collect_query_terms(expr, out),
        Expr::Extension { tag, payload } => {
            out.push(tag.to_string().to_lowercase());
            collect_query_terms(payload, out);
        }
        Expr::Annotated { expr, annotations } => {
            collect_query_terms(expr, out);
            for (key, value) in annotations {
                out.push(key.to_string().to_lowercase());
                collect_query_terms(value, out);
            }
        }
    }
}

fn memory_entry_matches(expr: &Expr, terms: &[String]) -> bool {
    if terms.is_empty() {
        return true;
    }
    let haystack = flatten_expr_text(expr);
    terms.iter().all(|term| haystack.contains(term))
}

pub(crate) fn flatten_expr_text(expr: &Expr) -> String {
    let mut out = Vec::new();
    collect_query_terms(expr, &mut out);
    out.join(" ")
}

pub(crate) fn append_memory_log(path: &Path, expr: &Expr) -> Result<()> {
    if let Some(parent) = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent).map_err(io_error)?;
    }
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .map_err(io_error)?;
    let payload = encode_frame(expr)?.0;
    let len = u64::try_from(payload.len())
        .map_err(|_| Error::HostError("memory log payload too large".to_owned()))?;
    file.write_all(&len.to_le_bytes()).map_err(io_error)?;
    file.write_all(&payload).map_err(io_error)
}

pub(crate) fn rewrite_memory_log(path: &Path, entries: &[Expr]) -> Result<()> {
    if let Some(parent) = path
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
    {
        fs::create_dir_all(parent).map_err(io_error)?;
    }
    let mut file = File::create(path).map_err(io_error)?;
    for entry in entries {
        let payload = encode_frame(entry)?.0;
        let len = u64::try_from(payload.len())
            .map_err(|_| Error::HostError("memory log payload too large".to_owned()))?;
        file.write_all(&len.to_le_bytes()).map_err(io_error)?;
        file.write_all(&payload).map_err(io_error)?;
    }
    Ok(())
}

pub(crate) fn load_memory_log(path: &Path) -> Result<Vec<Expr>> {
    if !path.exists() {
        return Ok(Vec::new());
    }
    let mut bytes = Vec::new();
    File::open(path)
        .map_err(io_error)?
        .read_to_end(&mut bytes)
        .map_err(io_error)?;
    let mut offset = 0usize;
    let mut entries = Vec::new();
    while offset < bytes.len() {
        if bytes.len().saturating_sub(offset) < 8 {
            return Err(Error::HostError(format!(
                "memory log {} ended with a partial length header",
                path.display()
            )));
        }
        let len = u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap());
        offset += 8;
        let len = usize::try_from(len)
            .map_err(|_| Error::HostError("memory log record length exceeds usize".to_owned()))?;
        if bytes.len().saturating_sub(offset) < len {
            return Err(Error::HostError(format!(
                "memory log {} ended with a partial record",
                path.display()
            )));
        }
        let (_, expr) = decode_frame(sim_kernel::CodecId(0), &bytes[offset..offset + len])?;
        entries.push(expr);
        offset += len;
    }
    Ok(entries)
}

pub(crate) fn lock_entries<'a>(
    entries: &'a SharedEntries,
    label: &'static str,
) -> Result<std::sync::MutexGuard<'a, Vec<Expr>>> {
    entries.lock().map_err(|_| Error::PoisonedLock(label))
}

pub(crate) fn io_error(err: std::io::Error) -> Error {
    Error::HostError(err.to_string())
}