atria-gpu-rs 0.1.0

CUDA-required GPU build of the Ablatio Triadum (ATria) centrality algorithm as a PluMA plugin
// The lib target is named `ATriaGPUPlugin` so the cdylib comes out as
// libATriaGPUPlugin.so (PluMA's loader convention). That triggers a
// non_snake_case lint on the crate name — suppress it here, it's
// intentional and PluMA-driven, not a style oversight.
#![allow(non_snake_case)]

//! # atria-gpu-rs
//!
//! CUDA-required GPU build of the Ablatio Triadum (ATria) centrality
//! algorithm, packaged as a PluMA plugin named `ATriaGPU`.
//!
//! ## Relationship to `atria-rs`
//!
//! `atria-rs` ships with three computational backends — CPU (default),
//! wgpu, and CUDA — selected at runtime via `ComputeBackend`. This crate
//! depends on `atria-rs` with the `cuda` feature compiled in and exposes
//! a thin wrapper that forces `ComputeBackend::Cuda` at construction
//! time. The intent is to ship a plugin that:
//!
//!   * is loaded by PluMA under a different name (`ATriaGPU`) so it can
//!     coexist with the canonical `ATria` (`atria-rs`) plugin in the
//!     same PluMA installation; and
//!   * fails loudly at startup if CUDA is unavailable, rather than
//!     silently falling back to the CPU implementation. Users who pass
//!     this plugin a network and run it on a non-CUDA host should be
//!     told to use `atria-rs` instead, not surprised by 100x slower
//!     wall-clock numbers months later.
//!
//! Algorithm and I/O semantics are byte-for-byte identical to
//! `atria-rs >= 1.4.0`:
//!
//!   * Input: signed-weighted correlation network as an N × N CSV
//!     (first row + first column are node labels; off-diagonal cells
//!     are signed edge weights).
//!   * Output: a Cytoscape NOA file matching the upstream C++ ATria
//!     plugin format, with ranked nodes formatted as
//!     `<name>\t#<rank> <name>\t<rank>` and unranked nodes as
//!     `<name>\t<name>\tNR`.
//!
//! See `atria-rs`'s `README.md` and `CHANGELOG.md` for the underlying
//! algorithm description and rank/tie semantics.

// atria-rs's lib target is named `ATriaPlugin` (so the cdylib comes out
// as libATriaPlugin.so for PluMA's lib<PluginName>Plugin.so loader). That
// makes `ATriaPlugin` the *crate* name we import from, with the struct
// inside it also called `ATriaPlugin`. We rename the import below so
// the rest of this file reads cleanly.
use ATriaPlugin::{ATriaPlugin as InnerATria, ComputeBackend};
use pluma_plugin_trait::PluMAPlugin;
use std::ffi::CStr;
use std::os::raw::c_char;

/// Thin wrapper around `ATriaPlugin` that hard-codes the CUDA backend.
///
/// The inner `ATriaPlugin` is constructed via `with_backend(Cuda)` and
/// its `set_backend` is never exposed, so callers can't accidentally
/// flip it back to CPU mid-pipeline. If the CUDA runtime is missing
/// or initialization fails, `effective_backend()` in `atria-rs` will
/// degrade to CPU; we surface that via a one-time warning during
/// the first `run()` so the user knows what happened.
#[derive(Debug)]
pub struct ATriaGPUPlugin {
    inner: InnerATria,
}

impl Default for ATriaGPUPlugin {
    fn default() -> Self {
        Self {
            inner: InnerATria::with_backend(ComputeBackend::Cuda),
        }
    }
}

impl ATriaGPUPlugin {
    /// Construct a new `ATriaGPUPlugin`.
    ///
    /// Equivalent to `ATriaGPUPlugin::default()`.
    pub fn new() -> Self {
        Self::default()
    }

    /// Borrow the underlying `ATriaPlugin` (mostly useful for tests that
    /// need to poke at `bacteria` / `orig_graph` / `ranks` directly).
    pub fn inner(&self) -> &InnerATria {
        &self.inner
    }
}

impl PluMAPlugin for ATriaGPUPlugin {
    fn input(&mut self, filepath: String) -> Result<(), Box<dyn std::error::Error>> {
        self.inner.input(filepath)
    }

    fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
        // Warn loudly if we were supposed to be on CUDA but degraded
        // to CPU at the last minute (e.g. missing libcudart at runtime).
        let effective = self.inner.effective_backend();
        if !matches!(effective, ComputeBackend::Cuda) {
            log::warn!(
                "[ATriaGPU] CUDA was requested but unavailable at runtime; \
                 falling back to {:?}. If this wasn't your intention, install \
                 the CUDA toolkit + a working NVIDIA driver and rerun.",
                effective
            );
        }
        self.inner.run()
    }

    fn output(&mut self, filepath: String) -> Result<(), Box<dyn std::error::Error>> {
        self.inner.output(filepath)
    }
}

// ===========================================================================
// PluMA FFI exports
//
// PluMA's Rust loader globs <plugins>/<dir>/Cargo.toml + libdir/lib<Name>Plugin.so
// and looks up `<Name>_plugin_*` symbols via dlsym, falling back to bare
// `plugin_*` names. We export the prefixed variant so this plugin can sit
// in `plugins/ATriaGPU/libATriaGPUPlugin.so` alongside the canonical
// `plugins/ATria/libATriaPlugin.so` without symbol collisions.
// ===========================================================================

#[unsafe(no_mangle)]
pub extern "C" fn ATriaGPU_plugin_create() -> *mut std::ffi::c_void {
    Box::into_raw(Box::new(ATriaGPUPlugin::new())) as *mut std::ffi::c_void
}

#[unsafe(no_mangle)]
pub extern "C" fn ATriaGPU_plugin_destroy(ptr: *mut std::ffi::c_void) {
    if !ptr.is_null() {
        unsafe {
            let _ = Box::from_raw(ptr as *mut ATriaGPUPlugin);
        }
    }
}

#[unsafe(no_mangle)]
pub extern "C" fn ATriaGPU_plugin_input(ptr: *mut std::ffi::c_void, filename: *const c_char) {
    if ptr.is_null() || filename.is_null() {
        eprintln!("[ATriaGPU] Error: null pointer in plugin_input");
        return;
    }
    unsafe {
        let plugin = &mut *(ptr as *mut ATriaGPUPlugin);
        let filename_str = match CStr::from_ptr(filename).to_str() {
            Ok(s) => s.to_string(),
            Err(e) => {
                eprintln!("[ATriaGPU] Error converting filename: {}", e);
                return;
            }
        };
        if let Err(e) = plugin.input(filename_str) {
            eprintln!("[ATriaGPU] Error in input: {}", e);
        }
    }
}

#[unsafe(no_mangle)]
pub extern "C" fn ATriaGPU_plugin_run(ptr: *mut std::ffi::c_void) {
    if ptr.is_null() {
        eprintln!("[ATriaGPU] Error: null pointer in plugin_run");
        return;
    }
    unsafe {
        let plugin = &mut *(ptr as *mut ATriaGPUPlugin);
        if let Err(e) = plugin.run() {
            eprintln!("[ATriaGPU] Error in run: {}", e);
        }
    }
}

#[unsafe(no_mangle)]
pub extern "C" fn ATriaGPU_plugin_output(ptr: *mut std::ffi::c_void, filename: *const c_char) {
    if ptr.is_null() || filename.is_null() {
        eprintln!("[ATriaGPU] Error: null pointer in plugin_output");
        return;
    }
    unsafe {
        let plugin = &mut *(ptr as *mut ATriaGPUPlugin);
        let filename_str = match CStr::from_ptr(filename).to_str() {
            Ok(s) => s.to_string(),
            Err(e) => {
                eprintln!("[ATriaGPU] Error converting filename: {}", e);
                return;
            }
        };
        if let Err(e) = plugin.output(filename_str) {
            eprintln!("[ATriaGPU] Error in output: {}", e);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn constructs_with_cuda_backend() {
        let p = ATriaGPUPlugin::new();
        // We can't assert effective_backend() == Cuda here because the
        // test environment may not have a GPU; but we *can* assert that
        // the wrapper at least configured the inner plugin to *prefer*
        // CUDA.
        let _ = p.inner();
    }

    #[test]
    fn ffi_create_and_destroy_round_trip() {
        let ptr = ATriaGPU_plugin_create();
        assert!(!ptr.is_null());
        ATriaGPU_plugin_destroy(ptr);
    }
}