// ---
// tags: cybergraph, rust
// crystal-type: source
// crystal-domain: cyber
// ---
//! Cybergraph public API โ€” five verbs over a local cyberlink processor.
//!
//! The lifecycle verbs are discrete and ordered:
//!
//!   intend(scope)        โ†’ declare an intent: signed scope, no STARK yet.
//!                          Persisted in bbg's intents dimension so abandonment
//!                          stays on the record.
//!   seal(key, signal)    โ†’ finalize a previously-declared intent into a signal
//!                          with a STARK proof. Validates the chain link from
//!                          intent โ†’ signal via sync's order_and_chain.
//!   link(signal)         โ†’ atomic one-shot submit. Used when the process is
//!                          a discrete local statement that does not need a
//!                          separate intent phase.
//!
//! The interaction verbs are read/observe:
//!
//!   subscribe(filter)    โ†’ register an event handler over a filter predicate.
//!   query(inf_script)    โ†’ run an inf (CozoScript datalog) query over the
//!                          relations cybergraph exposes โ€” schema in specs/query.md.

use std::collections::BTreeMap;

use bbg::{Bbg, IntentRecord, NeuronId, Particle};
use foculus::{ChainError, Signal, SignalChain};
use inf_eval::{eval, Ctx, Output};
use inf_parse::parse;
use inf_plan::plan;

use crate::source::BbgSource;

/// Structured description of an intended action.
///
/// Encoding is dialect-specific; cybergraph stores its scope_hash (Hemera).
#[derive(Clone)]
pub struct Scope {
    pub target:      Particle,
    pub predicate:   Vec<u8>,
    pub deadline:    Option<u64>,
    pub constraints: Vec<u8>,
}

impl Scope {
    /// Canonical hash of the scope = H(target โ€– predicate โ€– deadline โ€– constraints).
    pub fn hash(&self) -> Particle {
        let mut buf: Vec<u8> = Vec::with_capacity(32 + self.predicate.len() + 8 + self.constraints.len());
        buf.extend_from_slice(&self.target);
        buf.extend_from_slice(&self.predicate);
        buf.extend_from_slice(&self.deadline.unwrap_or(0).to_le_bytes());
        buf.extend_from_slice(&self.constraints);
        let h = hemera::hash(&buf);
        let b = h.as_bytes();
        let mut out = [0u8; 32];
        out[..b.len().min(32)].copy_from_slice(&b[..b.len().min(32)]);
        out
    }
}

/// A declared intent: neuron + scope + identity proof.
/// At declare time there is no STARK โ€” only the signature over (ฮฝ โ€– h0 โ€– scope_hash).
pub struct Intent {
    pub neuron:    NeuronId,
    pub h0:        u64,
    pub scope:     Scope,
    pub signature: [u8; 64],
}

/// Subscription filter โ€” which events the caller wants to observe.
#[derive(Clone)]
pub enum Filter {
    All,
    ByNeuron(NeuronId),
    ByTargetParticle(Particle),
}

/// Events emitted by cybergraph.
#[derive(Clone)]
pub enum Event {
    IntentDeclared { key: Particle, neuron: NeuronId, h0: u64 },
    SignalSealed   { intent_key: Particle, neuron: NeuronId, step: u64 },
    Linked         { neuron: NeuronId, step: u64 },
}

/// Errors from the public API.
#[derive(Debug)]
pub enum ApiError {
    /// Sync rejected the signal envelope (chain, VDF, or equivocation).
    SyncRejected(ChainError),
    /// Bbg rejected the cyberlinks (e.g., DoubleSpend).
    BbgRejected(bbg::InsertError),
    /// Intent key not found (seal called for unknown intent).
    UnknownIntent(Particle),
    /// Signal's resolved destination network does not match the network this
    /// node serves.
    WrongNetwork { expected: Particle, got: Particle },
}

/// A neuron's private network โ€” the default destination for its signals.
///
/// Derived deterministically as a card id from the neuron identity, so every
/// neuron has a sovereign private network with no minting step.
pub fn private_network(neuron: &NeuronId) -> Particle {
    let mut buf = Vec::with_capacity(8 + 32);
    buf.extend_from_slice(b"network:");
    buf.extend_from_slice(neuron);
    let h = hemera::hash(&buf);
    let b = h.as_bytes();
    let mut out = [0u8; 32];
    out[..b.len().min(32)].copy_from_slice(&b[..b.len().min(32)]);
    out
}

/// Errors from `query` โ€” one variant per inf pipeline stage.
#[derive(Debug)]
pub enum QueryError {
    Parse(String),
    Plan(String),
    Eval(String),
}

/// The cybergraph runtime: bbg state + per-neuron chains + event bus.
///
/// Scope is local-first โ€” a cybergraph instance processes whichever cyberlinks
/// it sees, at whatever scope it is configured for. Sync fans out distribution
/// to peers when present.
pub struct Cybergraph {
    pub bbg:         Bbg,
    pub chains:      BTreeMap<NeuronId, SignalChain>,
    /// The network this node serves. `None` accepts any network (local-first
    /// dev default); `Some(n)` enforces that each signal's resolved network is `n`.
    pub network:     Option<Particle>,
    subscribers:     Vec<(Filter, Box<dyn Fn(&Event) + Send + Sync>)>,
}

impl Cybergraph {
    pub fn new() -> Self {
        Self {
            bbg:         Bbg::new(),
            chains:      BTreeMap::new(),
            network:     None,
            subscribers: Vec::new(),
        }
    }

    /// A node serving exactly one network โ€” rejects any signal whose resolved
    /// destination network differs from `network`.
    pub fn serving(network: Particle) -> Self {
        Self { network: Some(network), ..Self::new() }
    }

    /// intend โ€” declare an unsealed intent. Persists the record and emits
    /// `IntentDeclared`. Sync layer would broadcast in a networked deployment.
    pub fn intend(&mut self, intent: Intent) -> Result<Particle, ApiError> {
        let record = IntentRecord {
            neuron:     intent.neuron,
            h0:         intent.h0,
            scope_hash: intent.scope.hash(),
            signature:  intent.signature,
        };
        let key = self.bbg.apply_intent(&record);
        self.emit(Event::IntentDeclared { key, neuron: intent.neuron, h0: intent.h0 });
        Ok(key)
    }

    /// seal โ€” finalize an intent into a complete signal carrying a STARK.
    /// The signal's chain ordering is validated by sync; cyberlinks land in bbg.
    pub fn seal(&mut self, intent_key: Particle, signal: Signal) -> Result<(), ApiError> {
        if !self.bbg.state.intents.contains_key(&intent_key) {
            return Err(ApiError::UnknownIntent(intent_key));
        }
        let (neuron, step) = self.commit_signal(signal)?;
        self.emit(Event::SignalSealed { intent_key, neuron, step });
        Ok(())
    }

    /// link โ€” atomic, one-shot submit. No prior intent required. Used for
    /// discrete local statements where the process does not need phasing.
    pub fn link(&mut self, signal: Signal) -> Result<(), ApiError> {
        let (neuron, step) = self.commit_signal(signal)?;
        self.emit(Event::Linked { neuron, step });
        Ok(())
    }

    /// subscribe โ€” register a handler over an event filter.
    pub fn subscribe<F>(&mut self, filter: Filter, handler: F)
    where
        F: Fn(&Event) + Send + Sync + 'static,
    {
        self.subscribers.push((filter, Box::new(handler)));
    }

    /// query โ€” run an inf query over local bbg state.
    ///
    /// Pipeline: parse โ†’ plan โ†’ eval against a `BbgSource` (the bbg aggregate
    /// relations: particles, neurons, axons, focus, karma, signals). Returns the
    /// inf `Output` (columns + rows, or a derived mutation batch).
    ///
    /// Release 0: reads run over the local snapshot and are not yet provable
    /// (`BbgSource::provable` is false) โ€” Lens openings over the committed root
    /// arrive in a later release. The query language itself lives in inf.
    pub fn query(&self, inf_script: &str) -> Result<Output, QueryError> {
        let prog = parse(inf_script)
            .map_err(|e| QueryError::Parse(format!("[{}:{}] {}", e.line, e.col, e.msg)))?;
        let ir = plan(&prog).map_err(|e| QueryError::Plan(e.msg))?;
        let src = BbgSource::new(&self.bbg.state);
        eval(&ir, &src, &Ctx::default()).map_err(|e| QueryError::Eval(e.msg))
    }

    // โ”€โ”€ internals โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

    /// Order, apply, and record a signal. Shared by `link` and `seal`.
    ///
    /// Three steps, in order:
    ///   1. chain ordering โ€” equivocation / step / prev (gate: sync)
    ///   2. state application โ€” cyberlinks land in bbg via `insert` (gate: double-spend)
    ///   3. header record โ€” the signal header enters the signals dimension
    ///
    /// Release 0: `box_moves` is always empty (CyberlinkRecord carries none), so
    /// `insert` is infallible here and the partial-failure window between step 1
    /// and step 2 cannot open. When conviction spends arrive (Release 1), this
    /// must become a validate-then-apply two-phase commit.
    fn commit_signal(&mut self, signal: Signal) -> Result<(NeuronId, u64), ApiError> {
        let neuron = signal.neuron;
        let step = signal.step;
        let link_count = signal.links.len() as u32;
        let height = signal.height;

        // Resolve the destination network: the SELF_NETWORK sentinel routes the
        // signal to the sender's private network.
        let network = if signal.network == foculus::SELF_NETWORK {
            private_network(&neuron)
        } else {
            signal.network
        };
        // Routing gate: a node serving a specific network rejects foreign signals.
        if let Some(serving) = self.network {
            if network != serving {
                return Err(ApiError::WrongNetwork { expected: serving, got: network });
            }
        }

        let bbg_signal = bridge_to_bbg(&signal);

        // 1. ordering gate
        self.chain_append(signal)?;
        // 2. apply cyberlinks to authenticated state
        self.bbg.insert(&bbg_signal).map_err(ApiError::BbgRejected)?;
        // 3. record the signal header
        self.bbg.apply_signal_record(step, bbg::SignalRecord {
            neuron,
            network,
            link_count,
            block_height: height,
            proof_hash: [0u8; 32],
        });

        Ok((neuron, step))
    }

    fn chain_append(&mut self, signal: Signal) -> Result<(), ApiError> {
        let chain = self.chains.entry(signal.neuron).or_default();
        chain.append(signal).map_err(ApiError::SyncRejected)
    }

    fn emit(&self, event: Event) {
        for (filter, handler) in &self.subscribers {
            if Self::matches(filter, &event) {
                handler(&event);
            }
        }
    }

    fn matches(filter: &Filter, event: &Event) -> bool {
        match (filter, event) {
            (Filter::All, _) => true,
            (Filter::ByNeuron(n), Event::IntentDeclared { neuron, .. })
            | (Filter::ByNeuron(n), Event::SignalSealed { neuron, .. })
            | (Filter::ByNeuron(n), Event::Linked { neuron, .. }) => n == neuron,
            (Filter::ByTargetParticle(_), _) => false, // target lookup needs scope; see Phase 5
        }
    }
}

/// Convert a sync signal into a bbg signal for state application.
///
/// Maps each `CyberlinkRecord` to a `bbg::Cyberlink` (from, to, token, amount,
/// valence). Release 0 limitation: `CyberlinkRecord` carries no box movements,
/// so `box_moves` is empty โ€” the local path applies cyberlinks only; conviction
/// spends arrive in Release 1.
fn bridge_to_bbg(signal: &Signal) -> bbg::Signal {
    bbg::Signal {
        neuron: signal.neuron,
        links: signal
            .links
            .iter()
            .map(|l| bbg::Cyberlink {
                from: l.from,
                to: l.to,
                token: l.token,
                amount: l.amount,
                valence: l.valence,
            })
            .collect(),
        box_moves: Vec::new(),
        height: signal.height,
    }
}

impl Default for Cybergraph {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use foculus::CyberlinkRecord;
    use std::sync::{Arc, Mutex};

    fn n(seed: u8) -> NeuronId { [seed; 32] }
    fn p(seed: u8) -> Particle { [seed; 32] }

    fn scope(target: Particle) -> Scope {
        Scope { target, predicate: b"do-thing".to_vec(), deadline: Some(100), constraints: vec![] }
    }

    fn intent(neuron: NeuronId, h0: u64, target: Particle) -> Intent {
        Intent { neuron, h0, scope: scope(target), signature: [0u8; 64] }
    }

    fn empty_signal(neuron: NeuronId, step: u64, prev: Particle) -> Signal {
        Signal { neuron, network: foculus::SELF_NETWORK, links: vec![], delta_pi: vec![], prev, step, height: 0, proof: None }
    }

    fn one_link_signal(neuron: NeuronId, step: u64, prev: Particle, from: Particle, to: Particle) -> Signal {
        Signal {
            neuron,
            network: foculus::SELF_NETWORK,
            links: vec![CyberlinkRecord { neuron, from, to, token: p(0), amount: 1, valence: 1, height: 0 }],
            delta_pi: vec![],
            prev,
            step,
            height: 0,
            proof: None,
        }
    }

    #[test]
    fn intend_persists_and_returns_key() {
        let mut g = Cybergraph::new();
        let key = g.intend(intent(n(1), 0, p(2))).unwrap();
        assert!(g.bbg.state.intents.contains_key(&key));
    }

    #[test]
    fn seal_unknown_intent_fails() {
        let mut g = Cybergraph::new();
        let s = empty_signal(n(1), 0, [0u8; 32]);
        let err = g.seal(p(99), s);
        assert!(matches!(err, Err(ApiError::UnknownIntent(_))));
    }

    #[test]
    fn intend_then_seal_succeeds() {
        let mut g = Cybergraph::new();
        let key = g.intend(intent(n(1), 0, p(2))).unwrap();
        let s = empty_signal(n(1), 0, [0u8; 32]);
        g.seal(key, s).unwrap();
        assert!(g.bbg.state.signals.contains_key(&0));
    }

    #[test]
    fn link_atomic_path() {
        let mut g = Cybergraph::new();
        let s = empty_signal(n(1), 0, [0u8; 32]);
        g.link(s).unwrap();
        assert!(g.bbg.state.signals.contains_key(&0));
    }

    #[test]
    fn link_applies_cyberlinks_to_bbg_state() {
        let mut g = Cybergraph::new();
        let s = one_link_signal(n(1), 0, [0u8; 32], p(2), p(3));
        g.link(s).unwrap();
        // The cyberlink (p2 โ†’ p3, amount 1) must reach bbg state, not just the header.
        let target = g.bbg.state.particles.get(&p(3)).expect("target particle materialized");
        assert_eq!(target.energy, 1, "target energy reflects the staked link");
        assert!(g.bbg.state.axons_out.get(&p(2)).is_some(), "outgoing axon recorded");
        assert!(g.bbg.state.axons_in.get(&p(3)).is_some(), "incoming axon recorded");
    }

    #[test]
    fn link_moves_the_root() {
        let mut g = Cybergraph::new();
        let before = g.bbg.state.root;
        g.link(one_link_signal(n(1), 0, [0u8; 32], p(2), p(3))).unwrap();
        assert_ne!(g.bbg.state.root, before, "applying cyberlinks advances BBG_root");
    }

    #[test]
    fn seal_applies_cyberlinks_to_bbg_state() {
        let mut g = Cybergraph::new();
        let key = g.intend(intent(n(1), 0, p(3))).unwrap();
        let s = one_link_signal(n(1), 0, [0u8; 32], p(2), p(3));
        g.seal(key, s).unwrap();
        assert_eq!(g.bbg.state.particles.get(&p(3)).unwrap().energy, 1);
    }

    #[test]
    fn link_accumulates_across_signals() {
        let mut g = Cybergraph::new();
        let s0 = one_link_signal(n(1), 0, [0u8; 32], p(2), p(3));
        let prev = s0.hash(); // chain link for the next signal
        g.link(s0).unwrap();
        let s1 = one_link_signal(n(1), 1, prev, p(2), p(3));
        g.link(s1).unwrap();
        // Two staked links onto p3 accumulate energy.
        assert_eq!(g.bbg.state.particles.get(&p(3)).unwrap().energy, 2);
    }

    #[test]
    fn subscribe_receives_matching_events() {
        let mut g = Cybergraph::new();
        let log: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
        let log2 = log.clone();
        g.subscribe(Filter::All, move |e| {
            let tag = match e {
                Event::IntentDeclared { .. } => "intent",
                Event::SignalSealed { .. }   => "seal",
                Event::Linked { .. }         => "link",
            };
            log2.lock().unwrap().push(tag);
        });
        g.intend(intent(n(1), 0, p(2))).unwrap();
        let s = empty_signal(n(1), 0, [0u8; 32]);
        g.link(s).unwrap();
        assert_eq!(*log.lock().unwrap(), vec!["intent", "link"]);
    }

    #[test]
    fn subscribe_by_neuron_filters() {
        let mut g = Cybergraph::new();
        let counter: Arc<Mutex<usize>> = Arc::new(Mutex::new(0));
        let c2 = counter.clone();
        g.subscribe(Filter::ByNeuron(n(1)), move |_| { *c2.lock().unwrap() += 1; });
        g.link(empty_signal(n(1), 0, [0u8; 32])).unwrap();
        g.link(empty_signal(n(2), 0, [0u8; 32])).unwrap();
        assert_eq!(*counter.lock().unwrap(), 1);
    }

    #[test]
    fn query_runs_inf_over_bbg_state() {
        // Seed two particles via a link, then query the focus relation.
        let mut g = Cybergraph::new();
        g.link(one_link_signal(n(1), 0, [0u8; 32], p(2), p(3))).unwrap();

        // particles relation exposes every materialized particle.
        let out = g.query("?[particle, energy] := particles{particle, energy}").expect("query runs");
        assert_eq!(out.columns, vec!["particle", "energy"]);
        // target p3 (energy 1) + axon-particle H(2,3) both materialize.
        assert!(!out.rows.is_empty(), "query returns the materialized particles");
    }

    #[test]
    fn query_parse_error_is_reported() {
        let g = Cybergraph::new();
        let err = g.query("this is not inf");
        assert!(matches!(err, Err(QueryError::Parse(_))));
    }
}

Homonyms

soft3/radio/iroh-blobs/src/api.rs
soft3/radio/iroh-gossip/src/api.rs
soft3/radio/iroh-docs/src/api.rs

Graph