taxa-core 0.1.0

taxa engine core: manifest model, formula AST→Polars Expr, bounded query generators over Polars.
//! Output node shape + Polars `AnyValue` → JSON / scalar helpers.

use indexmap::IndexMap;
use polars::prelude::*;
use serde::Serialize;
use serde_json::Value as Json;

#[derive(Debug, Serialize)]
pub struct TreeNode {
    pub name: String,
    pub measures: IndexMap<String, Json>,
    pub children: Vec<TreeNode>,
    #[serde(skip_serializing_if = "std::ops::Not::not")]
    pub is_other: bool,
    /// For an Other node: how many sibling branches were folded into it, so the
    /// frontend can show "+N others" rather than a hardcoded "+1". 0 otherwise.
    #[serde(skip)]
    pub n_folded: i64,
    /// This node sits at the fetched-depth boundary and has children in the data
    /// that weren't materialized — so a bounded (windowed) fetch should still
    /// treat it as zoomable. Internal; surfaced to the frontend as
    /// `has_more_children` by the adapter.
    #[serde(skip)]
    pub has_more: bool,
}

/// Days since the Unix epoch → ISO `YYYY-MM-DD` (civil-from-days, Hinnant).
pub fn days_to_iso(days: i32) -> String {
    let z = days as i64 + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    format!("{y:04}-{m:02}-{d:02}")
}

/// JSON projection preserving int-ness (so a summed count is `7`, not `7.0`).
pub fn av_to_json(av: &AnyValue) -> Json {
    match av {
        AnyValue::Null => Json::Null,
        AnyValue::Boolean(b) => Json::Bool(*b),
        AnyValue::Int8(v) => Json::from(*v as i64),
        AnyValue::Int16(v) => Json::from(*v as i64),
        AnyValue::Int32(v) => Json::from(*v as i64),
        AnyValue::Int64(v) => Json::from(*v),
        AnyValue::UInt8(v) => Json::from(*v as u64),
        AnyValue::UInt16(v) => Json::from(*v as u64),
        AnyValue::UInt32(v) => Json::from(*v as u64),
        AnyValue::UInt64(v) => Json::from(*v),
        AnyValue::Float32(v) => json_f64(*v as f64),
        AnyValue::Float64(v) => json_f64(*v),
        AnyValue::String(s) => Json::String(s.to_string()),
        AnyValue::StringOwned(s) => Json::String(s.to_string()),
        AnyValue::Date(days) => Json::String(days_to_iso(*days)),
        other => Json::String(format!("{other}")),
    }
}

fn json_f64(f: f64) -> Json {
    serde_json::Number::from_f64(f)
        .map(Json::Number)
        .unwrap_or(Json::Null)
}

/// Numeric view for ranking / ratio finalizers.
pub fn av_to_f64(av: &AnyValue) -> Option<f64> {
    match av {
        AnyValue::Int8(v) => Some(*v as f64),
        AnyValue::Int16(v) => Some(*v as f64),
        AnyValue::Int32(v) => Some(*v as f64),
        AnyValue::Int64(v) => Some(*v as f64),
        AnyValue::UInt8(v) => Some(*v as f64),
        AnyValue::UInt16(v) => Some(*v as f64),
        AnyValue::UInt32(v) => Some(*v as f64),
        AnyValue::UInt64(v) => Some(*v as f64),
        AnyValue::Float32(v) => Some(*v as f64),
        AnyValue::Float64(v) => Some(*v),
        AnyValue::Boolean(b) => Some(if *b { 1.0 } else { 0.0 }),
        _ => None,
    }
}

/// Display label for a level value (what Python's `str(...)` yields for keys).
pub fn av_to_label(av: &AnyValue) -> String {
    match av {
        AnyValue::Null => "None".into(),
        AnyValue::Boolean(b) => {
            if *b {
                "True".into()
            } else {
                "False".into()
            }
        }
        AnyValue::String(s) => s.to_string(),
        AnyValue::StringOwned(s) => s.to_string(),
        AnyValue::Date(days) => days_to_iso(*days),
        other => format!("{other}"),
    }
}

/// Stable fingerprint of a key tuple for parent→children maps (type-tagged so
/// int `1` and string "1" don't collide).
pub fn fingerprint(key: &[AnyValue]) -> String {
    let mut s = String::new();
    for av in key {
        match av {
            AnyValue::Null => s.push('N'),
            AnyValue::Boolean(b) => {
                s.push('b');
                s.push(if *b { 'T' } else { 'F' });
            }
            AnyValue::String(v) => {
                s.push('s');
                s.push_str(v);
            }
            AnyValue::StringOwned(v) => {
                s.push('s');
                s.push_str(v);
            }
            other => {
                s.push('x');
                s.push_str(&format!("{other}"));
            }
        }
        s.push('\u{1}');
    }
    s
}