yog 0.0.76

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
//! 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** — the agent is **at rest** (`Quiescent | Stopped`), **not**
//!    abandoned (`abandoned_oid` absent), **nobody dispatched it**, and its
//!    branch tip oid is unseen (the §6/§4.1 evidence for a rest is the branch
//!    tip). Ruled bl-2194: the strip is a **turn queue**, so rule 2 fires on
//!    rest, not on the wound — a clean turn-end and a failed one differ in the
//!    state badge, never in whether your turn has come. The field, the
//!    [`AttentionKind`] and the `ui.json` key keep the historical name
//!    `stopped`; the watermark's identity is the tip oid, unchanged, which is
//!    what makes the widening migration-free.
//!    Since bl-3592 rule 2 also asks **whose** turn the rest is: a
//!    conversation somebody else dispatched comes to rest into that one's
//!    inbox, so the turn is its parent's and never the operator's
//!    ([`rest_is_the_operators`]). DESIGN §6 rule 2 carries the argument and
//!    what it costs; this does not restate it.
//! 3. **budget** — `budget_oid` present and unseen.
//! 4. **conflicted** — `conflicted_oid` present and unseen.
//! 5. **mail** — a non-empty `pending` listing **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).
//!    Since bl-b43b it also says **which way** the rest came about: a rest whose
//!    latest response was refused at the provider rung earns
//!    [`AttentionKind::Refused`] instead, which is one firing said in the word
//!    that is true of it and never a second signal beside it.
//! 6. **held** — the capability control parked a tool invocation before it
//!    executed (`refs/litany/held/<id>`, §8.6). **Not seen-gated**, on mail's
//!    own precedent and for a stronger reason: a park costs the drone no
//!    process and no tokens and *nothing but an answer releases it*, so a
//!    watermark could only hide a conversation that cannot move. It self-clears
//!    when litany lifts the mark — which happens exactly when the answer lands
//!    and the branch re-adjudicates.
//!
//! 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").

/// **The signal vocabulary** — which signals exist and the sentence each says
/// (§6), cut off this file at §12's budget on `roster`'s own seam: a word is
/// not a derivation, and only one of the two changes when a seat needs the rule
/// stated rather than badged.
mod kind;
mod roster;
pub use roster::{
    RosterKey, next_attention, roster_order, sorted_roster, strip_total, workspace_count,
};

pub use kind::AttentionKind;
pub(crate) use kind::row_says;

use crate::git_tree::{Agent, AgentState};
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)`.

/// The per-agent attention detail: which of the six 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,
    /// Rule 2's rest is a **provider refusal** (bl-b43b): a refinement of
    /// [`stopped`](Self::stopped), never a signal beside it — it is only ever
    /// true where that one is, and it changes the word rather than the count.
    pub refused: bool,
    /// Rule 2's rest is a **truncation** (bl-ebef): the same kind of refinement
    /// on the other shape of a turn that did not end — the provider stopped at
    /// the request's output cap, so litany committed nothing and ran no tool
    /// (`Error::OutputTruncated`, litany bl-155f). Mutually exclusive with
    /// [`refused`](Self::refused) by construction: that one reads a failure
    /// sentence off a `Failed`/`Killed` tail, this one a `length` finish on a
    /// tail that framed cleanly.
    pub truncated: bool,
    pub budget: bool,
    pub conflicted: bool,
    pub mail: bool,
    pub held: bool,
    /// Rule 7 (bl-6f2f): a flag was raised here and its row's stamp is unseen.
    pub flagged: bool,
}

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

    /// **Which word rule 2's rest is said in** — the one home for the two
    /// refinements, so a third cannot be added as a fourth `if` somewhere else.
    /// Both are readings of a rest the operator did not ask for, and neither is
    /// a signal beside it: the count is unchanged, only the sentence.
    fn rest_word(self) -> AttentionKind {
        match (self.refused, self.truncated) {
            (true, _) => AttentionKind::Refused,
            (_, true) => AttentionKind::Truncated,
            _ => AttentionKind::Stopped,
        }
    }

    /// The firing kinds, in fixed badge order (notify, stop, budget, conflict,
    /// mail, held, flagged).
    pub fn kinds(self) -> Vec<AttentionKind> {
        [
            (self.notify, AttentionKind::Notify),
            (self.stopped, self.rest_word()),
            (self.budget, AttentionKind::Budget),
            (self.conflicted, AttentionKind::Conflicted),
            (self.mail, AttentionKind::Mail),
            (self.held, AttentionKind::Held),
            (self.flagged, AttentionKind::Flagged),
        ]
        .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]`).
///
/// **`siblings` is the workspace's whole agent set**, and rule 2 is why: whose
/// turn a rest is depends on whether anybody dispatched this conversation, and
/// that is a question about the set rather than about one row (module doc).
/// Every caller already holds the set — the rank sort, both rollups and the
/// roster walk each iterate it — so the parameter costs nothing but its name.
pub fn attention(
    agent: &Agent,
    siblings: &[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: rest_is_the_operators(agent, siblings)
            && rest_evidence(agent).is_some_and(|o| unseen(SeenKind::Stopped, &o)),
        // Which way that rest came about (bl-b43b, bl-ebef), off the two facts
        // the §3.5 classification already read: a refusal at the provider rung
        // and a turn cut off at the output cap are both wounds the operator did
        // not inflict, and `stopped` is `/stop`'s word.
        refused: agent.refused(),
        truncated: agent.truncated,
        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.is_empty() && lock_free(agent),
        // Rule 6 (§8.6): the park itself, unqualified by state. A held branch
        // has *no* driver — the seam exits after writing the mark — so gating
        // this on rest would only restate what the mark already asserts.
        held: agent.held.is_some(),
        // Rule 7 (bl-6f2f): the raising row's stamp is the evidence, and it
        // acknowledges exactly as an oid does.
        flagged: agent
            .flagged
            .as_ref()
            .is_some_and(|f| unseen(SeenKind::Flag, &f.at)),
    }
}

/// **Whose turn this conversation's rest is** (§6 rule 2 as amended, bl-3592):
/// the operator's only when nobody dispatched it.
///
/// A conversation forked by another — a compactor, a reviewer, a subagent, a
/// fan candidate — comes to rest **into its dispatcher's inbox** (litany ARCH
/// §2.6), so that rest is the parent's to take. **No turn is lost by the
/// suppression**: either the parent's driver takes the deposit, and nothing
/// needed the operator, or it does not, and the parent is at rest with mail
/// nobody is driving — rule 5, on the row that can act. Nothing but rule 2 is
/// suppressed, because nothing but a rest is a turn a parent can take.
///
/// The membership rule is the descent tree's, asked rather than restated
/// ([`parent_index`](crate::git_tree::parent_index)): a root id, an id outside
/// litany's grammar and a descendant whose dispatcher holds no ref all read as
/// nobody's child here exactly as they render at depth 0 there. DESIGN §6 rule
/// 2 carries why this is not a role test.
fn rest_is_the_operators(agent: &Agent, siblings: &[Agent]) -> bool {
    crate::git_tree::parent_index(siblings, &agent.agent_id).is_none()
}

/// The §6 **at rest** state class: a conversation that is not executing —
/// `Quiescent` (it came to rest cleanly) or `Stopped` (it came to rest wounded).
/// Rest is the general condition rule 2 fires on; *which way* it came to rest is
/// the state badge's job, never attention's (ruled bl-2194).
fn at_rest(state: AgentState) -> bool {
    matches!(state, AgentState::Quiescent | AgentState::Stopped)
}

/// The §6 rule-2 evidence for `agent`: the branch tip oid it is resting at, or
/// `None` when it is still running or has been abandoned (`refs/litany/abandoned`
/// is the will-not-retry assertion that suppresses the rule). **The one home for
/// rule 2's non-watermark gate** — the predicate here and the acknowledgement in
/// `app::focus` both call it, so the two can never drift into two answers.
pub fn rest_evidence(agent: &Agent) -> Option<String> {
    (at_rest(agent.state) && agent.abandoned_oid.is_none()).then(|| agent.tip_oid.clone())
}

/// The present acknowledgement evidence for one agent (§6): every signal oid
/// that exists right now — notify, the rest tip (unless abandoned), budget,
/// conflicted. Recording these as seen is what quiets attention; a later moved
/// ref is a different oid and re-arms (§4.1).
///
/// **The one definition**, read by both entries that acknowledge: the window's
/// focus tick ([`AppModel::focus_agent`](crate::AppModel::focus_agent)) and the
/// boundary's `seen` action
/// ([`queue::mark_seen`](crate::boundary::answer::queue::mark_seen)). Rules 5
/// (mail) and 6 (held) have no oid and appear here by design — each self-clears
/// when the world moves (a driver drains the inbox; litany lifts the hold mark
/// on the answer's re-adjudication), and no watermark may pretend to answer
/// them.
pub fn evidence(agent: &Agent) -> Vec<(SeenKind, String)> {
    [
        (SeenKind::Notify, agent.notify_oid.clone()),
        (SeenKind::Stopped, rest_evidence(agent)),
        (SeenKind::Budget, agent.budget_oid.clone()),
        (SeenKind::Conflicted, agent.conflicted_oid.clone()),
        (SeenKind::Flag, agent.flagged.as_ref().map(|f| f.at.clone())),
    ]
    .into_iter()
    .filter_map(|(kind, oid)| oid.map(|oid| (kind, oid)))
    .collect()
}

/// 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 (the [`at_rest`] pair) with the uncertainty
/// flag *clear*, whereas `Unknown` yields the same framing state with the flag
/// *set* (DESIGN §10). So `Free ⟺ at-rest ∧ ¬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 {
    at_rest(agent.state) && !agent.state_uncertain
}

#[cfg(test)]
mod tests;