use crate::ngg1::{self, Csr, Graph, GraphNode, Ngg1, Ngg1Error};
use serde::Serialize;
use std::collections::BTreeMap;
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct NggGraph {
graph: Graph,
}
impl NggGraph {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, Ngg1Error> {
Ok(Self {
graph: Ngg1::parse(bytes)?.to_graph().map_err(Ngg1Error::from)?,
})
}
}
#[wasm_bindgen]
impl NggGraph {
#[wasm_bindgen(js_name = nodeCount)]
pub fn node_count(&self) -> usize {
self.graph.nodes.len()
}
#[wasm_bindgen(js_name = edgeCount)]
pub fn edge_count(&self) -> usize {
self.graph.csr.col_idx.len()
}
pub fn ids(&self) -> Vec<u32> {
self.graph.nodes.iter().map(|n| n.id).collect()
}
pub fn positions(&self) -> Vec<f32> {
self.graph.nodes.iter().flat_map(|n| [n.x, n.y]).collect()
}
pub fn domains(&self) -> Vec<u16> {
self.graph.nodes.iter().map(|n| n.domain).collect()
}
pub fn categories(&self) -> Vec<u16> {
self.graph.nodes.iter().map(|n| n.category).collect()
}
pub fn flags(&self) -> Vec<u8> {
self.graph.nodes.iter().map(|n| n.flags).collect()
}
pub fn degrees(&self) -> Vec<u32> {
self.graph.nodes.iter().map(|n| n.degree).collect()
}
pub fn labels(&self) -> Vec<String> {
self.graph.nodes.iter().map(|n| n.label.clone()).collect()
}
pub fn iris(&self) -> Vec<String> {
self.graph.nodes.iter().map(|n| n.iri.clone()).collect()
}
#[wasm_bindgen(js_name = rowPtr)]
pub fn row_ptr(&self) -> Vec<u32> {
self.graph.csr.row_ptr.clone()
}
#[wasm_bindgen(js_name = colIdx)]
pub fn col_idx(&self) -> Vec<u32> {
self.graph.csr.col_idx.clone()
}
#[wasm_bindgen(js_name = edgeType)]
pub fn edge_type(&self) -> Vec<u8> {
self.graph.csr.edge_type.clone()
}
}
#[wasm_bindgen(js_name = decodeNgg1)]
pub fn decode_ngg1(bytes: &[u8]) -> Result<NggGraph, JsValue> {
NggGraph::from_bytes(bytes).map_err(|e| JsValue::from_str(&e.to_string()))
}
struct GraphParts<'a> {
seed: &'a [f32],
ids: &'a [u32],
domains: &'a [u16],
categories: &'a [u16],
flags: &'a [u8],
degrees: &'a [u32],
labels: Vec<String>,
iris: Vec<String>,
csr: Csr,
}
fn encode_parts(p: GraphParts<'_>) -> Result<Vec<u8>, String> {
let n = p.ids.len();
let positions = n.checked_mul(2).ok_or("NGG1: node count overflow")?;
for (name, found, expected) in [
("seed", p.seed.len(), positions),
("domains", p.domains.len(), n),
("categories", p.categories.len(), n),
("flags", p.flags.len(), n),
("degrees", p.degrees.len(), n),
("labels", p.labels.len(), n),
("iris", p.iris.len(), n),
] {
if found != expected {
return Err(format!("NGG1: {name} length {found}, expected {expected}"));
}
}
let nodes = p
.labels
.into_iter()
.zip(p.iris)
.enumerate()
.map(|(i, (label, iri))| GraphNode {
id: p.ids[i],
x: p.seed[i * 2],
y: p.seed[i * 2 + 1],
domain: p.domains[i],
category: p.categories[i],
flags: p.flags[i],
degree: p.degrees[i],
label,
iri,
})
.collect::<Vec<_>>();
ngg1::encode(&nodes, &p.csr).map_err(|e| format!("NGG1: {e}"))
}
#[wasm_bindgen(js_name = encodeNgg1)]
#[allow(clippy::too_many_arguments)]
pub fn encode_ngg1(
seed: &[f32],
ids: &[u32],
domains: &[u16],
categories: &[u16],
flags: &[u8],
degrees: &[u32],
labels: Vec<String>,
iris: Vec<String>,
row_ptr: &[u32],
col_idx: &[u32],
edge_type: &[u8],
) -> Result<Vec<u8>, JsValue> {
encode_parts(GraphParts {
seed,
ids,
domains,
categories,
flags,
degrees,
labels,
iris,
csr: Csr {
row_ptr: row_ptr.to_vec(),
col_idx: col_idx.to_vec(),
edge_type: edge_type.to_vec(),
},
})
.map_err(|e| JsValue::from_str(&e))
}
fn constants() -> BTreeMap<&'static str, u32> {
[
("NGG1_MAGIC", ngg1::NGG1_MAGIC),
("NGG1_VERSION", u32::from(ngg1::NGG1_VERSION)),
("NGG1_HEADER_SIZE", ngg1::NGG1_HEADER_SIZE as u32),
("NGG1_NODE_STRIDE", ngg1::NGG1_NODE_STRIDE as u32),
("EDGE_SUBCLASS", u32::from(ngg1::EDGE_SUBCLASS)),
("EDGE_RELATION", u32::from(ngg1::EDGE_RELATION)),
("FLAG_DOMAIN_ROOT", u32::from(ngg1::FLAG_DOMAIN_ROOT)),
("FLAG_CATEGORY_ROOT", u32::from(ngg1::FLAG_CATEGORY_ROOT)),
("FLAG_HAS_PAGE", u32::from(ngg1::FLAG_HAS_PAGE)),
("FLAG_BRIDGE", u32::from(ngg1::FLAG_BRIDGE)),
("FLAG_INDIVIDUAL", u32::from(ngg1::FLAG_INDIVIDUAL)),
("FLAG_META", u32::from(ngg1::FLAG_META)),
("DOMAIN_NONE", u32::from(ngg1::DOMAIN_NONE)),
("CATEGORY_NONE", u32::from(ngg1::CATEGORY_NONE)),
]
.into_iter()
.collect()
}
#[wasm_bindgen(js_name = ngg1Constants, unchecked_return_type = "Ngg1Constants")]
pub fn ngg1_constants() -> JsValue {
constants()
.serialize(&serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true))
.expect("constant string keys and u32 values are serialisable")
}
#[wasm_bindgen(typescript_custom_section)]
const TYPES: &str = r#"
export interface Ngg1Constants {
readonly NGG1_MAGIC: number;
readonly NGG1_VERSION: number;
readonly NGG1_HEADER_SIZE: number;
readonly NGG1_NODE_STRIDE: number;
readonly EDGE_SUBCLASS: number;
readonly EDGE_RELATION: number;
readonly FLAG_DOMAIN_ROOT: number;
readonly FLAG_CATEGORY_ROOT: number;
readonly FLAG_HAS_PAGE: number;
readonly FLAG_BRIDGE: number;
readonly FLAG_INDIVIDUAL: number;
readonly FLAG_META: number;
readonly DOMAIN_NONE: number;
readonly CATEGORY_NONE: number;
}
"#;
#[cfg(test)]
mod tests {
use super::*;
fn example<'a>(seed: &'a [f32]) -> GraphParts<'a> {
GraphParts {
seed,
ids: &[0, 1, 2],
domains: &[0, 0, 0],
categories: &[0, 1, 1],
flags: &[1, 0, 0],
degrees: &[1, 2, 1],
labels: vec!["AI".into(), "ML".into(), "NLP".into()],
iris: vec!["ng:0".into(), "ng:1".into(), "ng:2".into()],
csr: Csr {
row_ptr: vec![0, 0, 2, 2],
col_idx: vec![0, 2],
edge_type: vec![0, 1],
},
}
}
#[test]
fn binding_encoder_writes_the_owner_golden_byte_for_byte() {
assert_eq!(
include_bytes!("../../tests/fixtures/ngg1-3n2e.bin").as_slice(),
ngg1::worked_example()
);
let seed = [0.0, 0.0, 1.0, 0.0, 0.0, 1.0];
assert_eq!(
encode_parts(example(&seed)).unwrap(),
ngg1::worked_example()
);
}
#[test]
fn decoded_arrays_preserve_all_fields_and_are_independent() {
let decoded = NggGraph::from_bytes(&ngg1::worked_example()).unwrap();
assert_eq!(decoded.ids(), [0, 1, 2]);
assert_eq!(decoded.positions(), [0.0, 0.0, 1.0, 0.0, 0.0, 1.0]);
assert_eq!(decoded.domains(), [0, 0, 0]);
assert_eq!(decoded.categories(), [0, 1, 1]);
assert_eq!(decoded.flags(), [1, 0, 0]);
assert_eq!(decoded.degrees(), [1, 2, 1]);
assert_eq!(decoded.labels(), ["AI", "ML", "NLP"]);
assert_eq!(decoded.iris(), ["ng:0", "ng:1", "ng:2"]);
let mut row = decoded.row_ptr();
row[0] = 99;
assert_eq!(decoded.row_ptr(), [0, 0, 2, 2]);
assert_eq!(decoded.col_idx(), [0, 2]);
assert_eq!(decoded.edge_type(), [0, 1]);
}
#[test]
fn encode_rejects_short_arrays_and_invalid_csr_without_panics() {
let seed = [0.0; 6];
let mut parts = example(&seed);
parts.labels.pop();
assert!(encode_parts(parts).unwrap_err().contains("labels length"));
assert!(encode_parts(example(&seed[..5]))
.unwrap_err()
.contains("seed length"));
let mut parts = example(&seed);
parts.csr.col_idx[0] = 99;
assert!(encode_parts(parts).unwrap_err().contains("targets node 99"));
let mut parts = example(&seed);
parts.csr.row_ptr[0] = 1;
assert!(encode_parts(parts).unwrap_err().contains("CSR invariants"));
}
#[test]
fn non_estate_unicode_graph_roundtrips_with_unknown_codes() {
let seed = [3.25, -7.5, 8.0, 9.0, 10.0, 11.0];
let mut parts = example(&seed);
parts.domains = &[12, 99, ngg1::DOMAIN_NONE];
parts.categories = &[45, ngg1::CATEGORY_NONE, 2345];
parts.flags = &[0x80, ngg1::FLAG_META, 0];
parts.labels = vec!["Café".into(), "星雲".into(), "".into()];
parts.iris = vec![
"urn:custom:α".into(),
"https://example.org/星".into(),
"".into(),
];
parts.csr.edge_type[1] = 99;
let bytes = encode_parts(parts).unwrap();
let decoded = NggGraph::from_bytes(&bytes).unwrap();
assert_eq!(decoded.domains(), [12, 99, ngg1::DOMAIN_NONE]);
assert_eq!(decoded.categories(), [45, ngg1::CATEGORY_NONE, 2345]);
assert_eq!(decoded.flags(), [0x80, ngg1::FLAG_META, 0]);
assert_eq!(decoded.positions(), seed);
assert_eq!(decoded.labels(), ["Café", "星雲", ""]);
assert_eq!(decoded.edge_type(), [0, 99]);
assert_eq!(decoded.graph.encode().unwrap(), bytes);
}
#[test]
fn constants_are_owner_values_and_decode_rejects_malformed_bytes() {
assert_eq!(constants()["CATEGORY_NONE"], u32::from(ngg1::CATEGORY_NONE));
assert_eq!(constants()["FLAG_META"], u32::from(ngg1::FLAG_META));
assert!(NggGraph::from_bytes(&[]).is_err());
let mut bytes = ngg1::worked_example();
*bytes.last_mut().unwrap() = 0xff;
assert!(NggGraph::from_bytes(&bytes).is_err());
}
}