yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The attention model (DESIGN §6, §15 Y10): the derived per-agent predicate,
//! its per-signal detail for badges, the workspace/strip rollups, the
//! jump-to-next-attention control, and the roster sort.
//!
//! Everything here is a **pure function** of injected snapshots — the
//! [`git_tree::Agent`](crate::git_tree::Agent) views plus a `seen`-lookup
//! closure over the `ui.json` watermarks (§4.1). The narrowest coupling: the
//! module needs exactly one query from `ui_state` — "is this evidence oid
//! acknowledged?" ([`UiState::is_seen`](crate::ui_state::UiState::is_seen)) —
//! so it takes that one closure, never the whole document nor a `Clock`.
//!
//! # The predicate (DESIGN §6)
//!
//! [`attention`] is true when any signal fires:
//!
//! 1. **notify** — `notify_oid` present and unseen.
//! 2. **stopped** — `state == Stopped`, **not** abandoned (`abandoned_oid`
//!    absent), and the branch tip oid unseen (the §6/§4.1 evidence for a stop
//!    is the branch tip).
//! 3. **budget** — `budget_oid` present and unseen.
//! 4. **conflicted** — `conflicted_oid` present and unseen.
//! 5. **mail** — `pending_messages > 0` **and** the lock is definitely `Free`
//!    (a driver-absence stall). Signals 1–4 are seen-gated on `ui.json`; **mail
//!    is not** — it self-clears when a driver drains the inbox (§6 rule 5).
//!
//! Signals 1–4 "re-arm" automatically: the watermark is an oid, so a moved ref
//! (new oid ≠ the seen one) fires again (§4.1 "A moved ref re-notifies").

use crate::git_tree::{Agent, AgentState, descent_order};
use crate::ui_state::SeenKind;

// The seen-lookup every predicate here takes — does `(kind, ws, agent, oid)`
// carry an acknowledgement watermark in `ui.json` (§6)? Threaded as a bare
// `&dyn Fn(..)` (a type alias `dyn Fn` would bake in `'static` and reject the
// shell's `self`-capturing closure; `&dyn` defaults to the reference's own,
// elided lifetime). Production passes `&|k, w, a, o| ui_state.is_seen(k, w, a, o)`.

/// One firing signal — the per-badge detail (§6, §3.5 badge rendering).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttentionKind {
    Notify,
    Stopped,
    Budget,
    Conflicted,
    Mail,
}

/// The per-agent attention detail: which of the five signals fire. The bare
/// predicate is [`Attention::any`]; [`Attention::kinds`] lists the firing
/// kinds for badge rendering.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Attention {
    pub notify: bool,
    pub stopped: bool,
    pub budget: bool,
    pub conflicted: bool,
    pub mail: bool,
}

impl Attention {
    /// The §6 predicate: any signal firing.
    pub fn any(self) -> bool {
        self.notify || self.stopped || self.budget || self.conflicted || self.mail
    }

    /// The firing kinds, in fixed badge order (notify, stop, budget, conflict,
    /// mail).
    pub fn kinds(self) -> Vec<AttentionKind> {
        [
            (self.notify, AttentionKind::Notify),
            (self.stopped, AttentionKind::Stopped),
            (self.budget, AttentionKind::Budget),
            (self.conflicted, AttentionKind::Conflicted),
            (self.mail, AttentionKind::Mail),
        ]
        .into_iter()
        .filter_map(|(on, kind)| on.then_some(kind))
        .collect()
    }
}

/// The §6 per-agent predicate over injected snapshots. `ws` is the workspace's
/// seen-key path (§4.1 `seen[ws][agent]`).
pub fn attention(
    agent: &Agent,
    ws: &str,
    seen: &dyn Fn(SeenKind, &str, &str, &str) -> bool,
) -> Attention {
    let id = agent.agent_id.as_str();
    let unseen = |kind, oid: &str| !seen(kind, ws, id, oid);
    Attention {
        notify: agent
            .notify_oid
            .as_deref()
            .is_some_and(|o| unseen(SeenKind::Notify, o)),
        stopped: agent.state == AgentState::Stopped
            && agent.abandoned_oid.is_none()
            && unseen(SeenKind::Stopped, &agent.tip_oid),
        budget: agent
            .budget_oid
            .as_deref()
            .is_some_and(|o| unseen(SeenKind::Budget, o)),
        conflicted: agent
            .conflicted_oid
            .as_deref()
            .is_some_and(|o| unseen(SeenKind::Conflicted, o)),
        mail: agent.pending_messages > 0 && lock_free(agent),
    }
}

/// The §6 rule-5 driver-absence condition: the executor lock is definitely
/// `Free`, not merely `Unknown`. The classifier (`git_tree::state`) collapses a
/// `Free` probe to a framing state (`Quiescent`/`Stopped`) with the uncertainty
/// flag *clear*, whereas `Unknown` yields the same framing state with the flag
/// *set* (DESIGN §10). So `Free ⟺ framing-state ∧ ¬uncertain` — a `Live` /
/// `InFlight` agent (lock `Held`) is never mail-stalled; an `Unknown` one is
/// hidden, never a false stall.
fn lock_free(agent: &Agent) -> bool {
    matches!(agent.state, AgentState::Quiescent | AgentState::Stopped) && !agent.state_uncertain
}

/// Sort rank for the roster (§6): attention (0) > running (1) > idle (2).
fn rank(agent: &Agent, ws: &str, seen: &dyn Fn(SeenKind, &str, &str, &str) -> bool) -> u8 {
    if attention(agent, ws, seen).any() {
        0
    } else if matches!(agent.state, AgentState::Live | AgentState::InFlight) {
        1
    } else {
        2
    }
}

/// The §6 roster sort within one workspace: attention > running > idle, ties
/// broken by descent order (§2.3). Returns the `agents` indices in that order —
/// stable, so the descent order survives within each rank group.
pub fn sorted_roster(
    agents: &[Agent],
    ws: &str,
    seen: &dyn Fn(SeenKind, &str, &str, &str) -> bool,
) -> Vec<usize> {
    let mut order: Vec<usize> = descent_order(agents)
        .into_iter()
        .map(|row| row.index)
        .collect();
    order.sort_by_key(|&i| agents.get(i).map(|a| rank(a, ws, seen)));
    order
}

/// The §6 workspace rollup: how many agents there have attention. The boolean
/// "workspace has attention" (§6 "max over its agents") is `count > 0`.
pub fn workspace_count(
    agents: &[Agent],
    ws: &str,
    seen: &dyn Fn(SeenKind, &str, &str, &str) -> bool,
) -> usize {
    agents
        .iter()
        .filter(|a| attention(a, ws, seen).any())
        .count()
}

/// The §6 strip total: attention-bearing agents summed across all workspaces,
/// each a `(seen-key path, agent set)` pair (§4.1).
pub fn strip_total(
    workspaces: &[(&str, &[Agent])],
    seen: &dyn Fn(SeenKind, &str, &str, &str) -> bool,
) -> usize {
    workspaces
        .iter()
        .map(|(path, agents)| workspace_count(agents, path, seen))
        .sum()
}

/// One entry in the flattened navigator roster (§6): the workspace's seen-key
/// path, an agent id, and whether that agent bears attention. Owned — it
/// outlives the snapshot borrows it derives from. The `attention` flag is
/// computed once as the roster is built (the rank sort needs it) and carried so
/// [`next_attention`] reads it rather than re-deriving.
#[derive(Clone)]
pub struct RosterKey {
    pub ws: String,
    pub agent_id: String,
    pub attention: bool,
}

impl RosterKey {
    /// Whether this entry is the `(ws, agent)` focus position.
    fn is_at(&self, focus: (&str, &str)) -> bool {
        self.ws == focus.0 && self.agent_id == focus.1
    }
}

/// The full derived roster across workspaces (§6): **path order across**,
/// [`sorted_roster`] **within**, flattened for the navigator and for
/// [`next_attention`]. Each workspace is a `(seen-key path, agent set)` pair.
pub fn roster_order(
    workspaces: &[(&str, &[Agent])],
    seen: &dyn Fn(SeenKind, &str, &str, &str) -> bool,
) -> Vec<RosterKey> {
    let mut wss: Vec<(&str, &[Agent])> = workspaces.to_vec();
    wss.sort_by_key(|(path, _)| *path);
    let mut out = Vec::new();
    for (path, agents) in wss {
        out.extend(
            sorted_roster(agents, path, seen)
                .into_iter()
                .filter_map(|i| agents.get(i))
                .map(|agent| RosterKey {
                    ws: path.to_string(),
                    agent_id: agent.agent_id.clone(),
                    attention: attention(agent, path, seen).any(),
                }),
        );
    }
    out
}

/// Jump-to-next-attention (§6): over the ordered `roster`, the next entry with
/// attention *after* `focus`, wrapping. `focus == None` starts from the front.
/// When `focus` is the only attention it is returned (a full wrap); when
/// nothing has attention, `None`. `focus` is a `(ws, agent)` position.
pub fn next_attention(roster: &[RosterKey], focus: Option<(&str, &str)>) -> Option<RosterKey> {
    let n = roster.len();
    if n == 0 {
        return None;
    }
    let mut start = 0;
    if let Some(f) = focus
        && let Some(i) = roster.iter().position(|e| e.is_at(f))
    {
        start = i + 1;
    }
    (0..n)
        .filter_map(|step| roster.get((start + step) % n))
        .find(|e| e.attention)
        .cloned()
}

/// Step `delta` entries (±1 for ↓/↑, §11 keyboard nav) from `focus` through the
/// ordered `roster`, wrapping. Unlike [`next_attention`] this visits *every*
/// entry, not only attention-bearing ones — it is plain roster traversal. A
/// `None`/unknown focus starts before the front, so `+1` lands on the first
/// entry and `-1` on the last; an empty roster yields `None`.
pub fn step(roster: &[RosterKey], focus: Option<(&str, &str)>, delta: isize) -> Option<RosterKey> {
    let n = roster.len();
    if n == 0 {
        return None;
    }
    let here = focus.and_then(|f| roster.iter().position(|e| e.is_at(f)));
    let next = match here {
        Some(i) => (i as isize + delta).rem_euclid(n as isize) as usize,
        None if delta >= 0 => 0,
        None => n - 1,
    };
    roster.get(next).cloned()
}

#[cfg(test)]
mod tests;