chappi-core 0.1.0

Typed cognitive graph, patch, reentry, projection, and measurement engine for Chappi
Documentation
use std::collections::{HashMap, HashSet};

use chappi_syntax::{Atom, Diagnostic, Form, FormKind, Span};

use crate::diagnostic::error;
use crate::model::forms_semantically_equal;
use crate::{CognitiveState, Node, NodeKind};

type FocusDecodeResult<T> = Result<T, Box<Diagnostic>>;

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FocusStream {
    pub id: String,
    pub frames: Vec<FocusFrame>,
}

#[derive(Clone, Debug)]
pub struct FocusFrame {
    pub stream_id: String,
    pub sequence: u64,
    pub owner: String,
    pub transition: TransitionKind,
    pub previous_sequence: Option<u64>,
    pub foreground: Vec<String>,
    pub background: Vec<String>,
    pub residue: Vec<String>,
    pub trigger: Form,
    pub span: Span,
}

impl PartialEq for FocusFrame {
    fn eq(&self, other: &Self) -> bool {
        self.stream_id == other.stream_id
            && self.sequence == other.sequence
            && self.owner == other.owner
            && self.transition == other.transition
            && self.previous_sequence == other.previous_sequence
            && bucket_eq(&self.foreground, &other.foreground)
            && bucket_eq(&self.background, &other.background)
            && bucket_eq(&self.residue, &other.residue)
            && forms_semantically_equal(&self.trigger, &other.trigger)
    }
}

impl Eq for FocusFrame {}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransitionKind {
    Evoked,
    Associated,
    Interrupted,
    Returned,
    Reframed,
    Resolved,
}

impl TransitionKind {
    pub(crate) fn decode(value: &str) -> Option<Self> {
        match value {
            "evoked" => Some(Self::Evoked),
            "associated" => Some(Self::Associated),
            "interrupted" => Some(Self::Interrupted),
            "returned" => Some(Self::Returned),
            "reframed" => Some(Self::Reframed),
            "resolved" => Some(Self::Resolved),
            _ => None,
        }
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Evoked => "evoked",
            Self::Associated => "associated",
            Self::Interrupted => "interrupted",
            Self::Returned => "returned",
            Self::Reframed => "reframed",
            Self::Resolved => "resolved",
        }
    }
}

pub(crate) fn decode_focus(items: &[Form], span: Span) -> FocusDecodeResult<FocusFrame> {
    if items.len() != 10 {
        return Err(error(
            "invalid-focus-arity",
            span,
            vec!["focus".into()],
            "a focus frame must contain stream, sequence, owner, transition, previous sequence, fg, bg, residue, and trigger",
            None,
        )
        .into());
    }

    let stream_id = required_symbol(&items[1], "stream ID", &["focus", "stream"])?;
    let sequence = required_u64(&items[2], "sequence", &["focus", &stream_id, "sequence"])?;
    let owner = required_symbol(
        &items[3],
        "owner entity ID",
        &["focus", &stream_id, "owner"],
    )?;
    let transition_text = required_symbol(
        &items[4],
        "transition",
        &["focus", &stream_id, "transition"],
    )?;
    let Some(transition) = TransitionKind::decode(&transition_text) else {
        return Err(error(
            "invalid-focus-transition",
            items[4].span,
            vec!["focus".into(), stream_id.clone(), "transition".into()],
            "transition must be `evoked`, `associated`, `interrupted`, `returned`, `reframed`, or `resolved`",
            None,
        )
        .into());
    };
    let previous_sequence = match symbol(&items[5]) {
        Some("none") => None,
        _ => Some(required_u64(
            &items[5],
            "previous sequence",
            &["focus", &stream_id, "previous_sequence"],
        )?),
    };

    Ok(FocusFrame {
        stream_id: stream_id.clone(),
        sequence,
        owner,
        transition,
        previous_sequence,
        foreground: decode_bucket(&items[6], "fg", &stream_id)?,
        background: decode_bucket(&items[7], "bg", &stream_id)?,
        residue: decode_bucket(&items[8], "residue", &stream_id)?,
        trigger: items[9].clone(),
        span,
    })
}

fn decode_bucket(
    form: &Form,
    expected_head: &str,
    stream_id: &str,
) -> FocusDecodeResult<Vec<String>> {
    let FormKind::List(items) = &form.kind else {
        return Err(invalid_bucket(form, expected_head, stream_id));
    };
    if items.first().and_then(symbol) != Some(expected_head) {
        return Err(invalid_bucket(form, expected_head, stream_id));
    }
    let mut ids = items[1..]
        .iter()
        .map(|item| {
            required_symbol(
                item,
                "focus node reference",
                &["focus", stream_id, expected_head],
            )
        })
        .collect::<Result<Vec<_>, _>>()?;
    ids.sort();
    Ok(ids)
}

fn bucket_eq(left: &[String], right: &[String]) -> bool {
    let mut left = left.iter().map(String::as_str).collect::<Vec<_>>();
    let mut right = right.iter().map(String::as_str).collect::<Vec<_>>();
    left.sort_unstable();
    right.sort_unstable();
    left == right
}

fn invalid_bucket(form: &Form, expected_head: &str, stream_id: &str) -> Box<Diagnostic> {
    error(
        "invalid-focus-bucket",
        form.span,
        vec!["focus".into(), stream_id.into(), expected_head.into()],
        format!("focus bucket must be `({expected_head} <node-id>...)`"),
        None,
    )
    .into()
}

fn required_symbol(form: &Form, name: &str, path: &[&str]) -> FocusDecodeResult<String> {
    symbol(form).map(ToOwned::to_owned).ok_or_else(|| {
        error(
            "expected-symbol",
            form.span,
            path.iter().map(ToString::to_string).collect(),
            format!("{name} must be a symbol"),
            None,
        )
        .into()
    })
}

fn required_u64(form: &Form, name: &str, path: &[&str]) -> FocusDecodeResult<u64> {
    let FormKind::Atom(Atom::Integer(value)) = &form.kind else {
        return Err(invalid_integer(form, name, path));
    };
    value.parse().map_err(|_| invalid_integer(form, name, path))
}

fn invalid_integer(form: &Form, name: &str, path: &[&str]) -> Box<Diagnostic> {
    error(
        "invalid-focus-sequence-number",
        form.span,
        path.iter().map(ToString::to_string).collect(),
        format!("{name} must be a non-negative 64-bit integer"),
        None,
    )
    .into()
}

fn symbol(form: &Form) -> Option<&str> {
    match &form.kind {
        FormKind::Atom(Atom::Symbol(value)) => Some(value),
        _ => None,
    }
}

pub(crate) fn validate_focus_streams(
    state: &CognitiveState,
    nodes_by_id: &HashMap<&str, &Node>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    let mut seen_streams = HashSet::new();
    for stream in &state.focus_streams {
        if stream.frames.is_empty() {
            diagnostics.push(error(
                "empty-focus-stream",
                empty_span(),
                vec!["focus".into(), stream.id.clone()],
                format!(
                    "focus stream `{}` must contain at least one frame",
                    stream.id
                ),
                None,
            ));
        }
        if !seen_streams.insert(stream.id.as_str()) {
            let span = stream
                .frames
                .first()
                .map_or_else(empty_span, |frame| frame.span);
            diagnostics.push(error(
                "duplicate-focus-stream",
                span,
                vec!["focus".into(), stream.id.clone()],
                format!("focus stream `{}` is defined more than once", stream.id),
                None,
            ));
        }
        for (index, frame) in stream.frames.iter().enumerate() {
            if frame.stream_id != stream.id {
                diagnostics.push(error(
                    "focus-stream-mismatch",
                    frame.span,
                    vec![
                        "focus".into(),
                        stream.id.clone(),
                        frame.sequence.to_string(),
                    ],
                    format!(
                        "frame names stream `{}` but is stored in stream `{}`",
                        frame.stream_id, stream.id
                    ),
                    None,
                ));
            }
            let expected = index as u64;
            if index == 0 {
                if frame.sequence != 0
                    || frame.transition != TransitionKind::Evoked
                    || frame.previous_sequence.is_some()
                {
                    diagnostics.push(error(
                        "invalid-focus-start",
                        frame.span,
                        vec!["focus".into(), stream.id.clone(), frame.sequence.to_string()],
                        "the first frame must have sequence 0, transition `evoked`, and previous `none`",
                        None,
                    ));
                }
            } else if frame.sequence != expected || frame.previous_sequence != Some(expected - 1) {
                diagnostics.push(error(
                    "invalid-focus-sequence",
                    frame.span,
                    vec![
                        "focus".into(),
                        stream.id.clone(),
                        frame.sequence.to_string(),
                    ],
                    format!(
                        "focus frame must have sequence {expected} and name previous sequence {}",
                        expected - 1
                    ),
                    None,
                ));
            }

            match nodes_by_id.get(frame.owner.as_str()) {
                None => diagnostics.push(error(
                    "undefined-focus-owner",
                    frame.span,
                    vec![
                        "focus".into(),
                        stream.id.clone(),
                        frame.sequence.to_string(),
                        "owner".into(),
                    ],
                    format!(
                        "focus owner `{}` does not name an existing entity node",
                        frame.owner
                    ),
                    None,
                )),
                Some(node) if !matches!(node.kind, NodeKind::Entity { .. }) => {
                    diagnostics.push(error(
                        "invalid-focus-owner-kind",
                        frame.span,
                        vec![
                            "focus".into(),
                            stream.id.clone(),
                            frame.sequence.to_string(),
                            "owner".into(),
                        ],
                        format!("focus owner `{}` names a non-entity node", frame.owner),
                        None,
                    ))
                }
                Some(_) => {}
            }

            let mut all_seen = HashSet::new();
            for (bucket_name, bucket) in [
                ("fg", &frame.foreground),
                ("bg", &frame.background),
                ("residue", &frame.residue),
            ] {
                let mut bucket_seen = HashSet::new();
                for reference in bucket {
                    if !bucket_seen.insert(reference.as_str()) {
                        diagnostics.push(error(
                            "duplicate-focus-reference",
                            frame.span,
                            vec![
                                "focus".into(),
                                stream.id.clone(),
                                frame.sequence.to_string(),
                                bucket_name.into(),
                            ],
                            format!(
                                "focus bucket `{bucket_name}` contains `{reference}` more than once"
                            ),
                            None,
                        ));
                        continue;
                    }
                    if !all_seen.insert(reference.as_str()) {
                        diagnostics.push(error(
                            "overlapping-focus-reference",
                            frame.span,
                            vec![
                                "focus".into(),
                                stream.id.clone(),
                                frame.sequence.to_string(),
                                bucket_name.into(),
                            ],
                            format!("node `{reference}` occurs in more than one focus bucket"),
                            None,
                        ));
                    }
                    if !nodes_by_id.contains_key(reference.as_str()) {
                        diagnostics.push(error(
                            "undefined-focus-reference",
                            frame.span,
                            vec![
                                "focus".into(),
                                stream.id.clone(),
                                frame.sequence.to_string(),
                                bucket_name.into(),
                            ],
                            format!("focus reference `{reference}` does not name an existing node"),
                            None,
                        ));
                    }
                }
            }
        }
    }
}

fn empty_span() -> Span {
    Span {
        start: 0,
        end: 0,
        start_line: 1,
        start_column: 1,
        end_line: 1,
        end_column: 1,
    }
}