helena 0.1.0

Core types and component interfaces for helena, a latent data-to-waveform generation platform.
Documentation
//! Provenance as structure (A6, PRD NFR-010).
//!
//! A [`Fingerprint`] is a typed SHA-256 digest with exactly one canonical
//! construction path ([`Fingerprint::of`]); composed artifacts combine
//! fingerprints Merkle-style ([`Fingerprint::combine`]). Persisted metadata
//! travels in a [`SidecarEnvelope`] carrying an explicit [`SchemaVersion`],
//! replacing convention-based `#[serde(default)]` back-fills with a checked,
//! versioned decode.

use serde::{Deserialize, Serialize, de::DeserializeOwned};
use sha2::{Digest, Sha256};

use crate::error::{Error, Result};
use crate::seams::SourceBatch;

/// Hex-encode bytes under the `sha256:` scheme prefix. The low-level
/// primitive for callers hashing raw bytes (file payloads); typed values go
/// through [`Fingerprint::of`].
pub fn sha256_hex(bytes: &[u8]) -> String {
    let digest = Sha256::digest(bytes);
    format!("sha256:{digest:x}")
}

/// A typed SHA-256 fingerprint.
///
/// Displayed and serialized as `sha256:<hex>`; deserialization parses and
/// fails closed on a malformed or truncated digest, so a fingerprint field
/// can never hold a free-form string.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct Fingerprint([u8; 32]);

impl Fingerprint {
    /// Fingerprint a serializable value through the one canonical path:
    /// serde_json bytes into SHA-256. Deterministic for helena's vocabulary
    /// (maps are `BTreeMap`-backed, struct field order is fixed by the type).
    pub fn of<T: Serialize + ?Sized>(value: &T) -> Result<Fingerprint> {
        let bytes = serde_json::to_vec(value)?;
        Ok(Self(Sha256::digest(&bytes).into()))
    }

    /// Combine fingerprints Merkle-style: the digest of the length-prefixed
    /// concatenation. Order-sensitive by design — provenance chains are
    /// ordered.
    pub fn combine(parts: &[Fingerprint]) -> Fingerprint {
        let mut hasher = Sha256::new();
        hasher.update((parts.len() as u64).to_le_bytes());
        for part in parts {
            hasher.update(part.0);
        }
        Self(hasher.finalize().into())
    }

    /// The raw digest bytes.
    pub fn bytes(&self) -> &[u8; 32] {
        &self.0
    }
}

impl std::fmt::Display for Fingerprint {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "sha256:")?;
        for byte in self.0 {
            write!(f, "{byte:02x}")?;
        }
        Ok(())
    }
}

impl std::fmt::Debug for Fingerprint {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "Fingerprint({self})")
    }
}

impl std::str::FromStr for Fingerprint {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self> {
        let hex = s
            .strip_prefix("sha256:")
            .ok_or_else(|| Error::validation("fingerprint must use the sha256: scheme"))?;
        if hex.len() != 64 {
            return Err(Error::validation(format!(
                "fingerprint digest must be 64 hex chars, got {}",
                hex.len()
            )));
        }
        let mut bytes = [0u8; 32];
        for (i, chunk) in hex.as_bytes().chunks_exact(2).enumerate() {
            let hi = hex_val(chunk[0])?;
            let lo = hex_val(chunk[1])?;
            bytes[i] = (hi << 4) | lo;
        }
        Ok(Self(bytes))
    }
}

fn hex_val(c: u8) -> Result<u8> {
    match c {
        b'0'..=b'9' => Ok(c - b'0'),
        b'a'..=b'f' => Ok(c - b'a' + 10),
        b'A'..=b'F' => Ok(c - b'A' + 10),
        _ => Err(Error::validation("fingerprint digest must be hex")),
    }
}

impl From<Fingerprint> for String {
    fn from(fp: Fingerprint) -> String {
        fp.to_string()
    }
}

impl TryFrom<String> for Fingerprint {
    type Error = Error;

    fn try_from(s: String) -> Result<Self> {
        s.parse()
    }
}

/// The provenance fingerprint of a source batch (PRD NFR-010).
///
/// Only mintable from an actual [`SourceBatch`], so an artifact field of
/// this type provably describes source data. Rejects non-finite features:
/// JSON renders NaN/∞ as `null`, which would collapse distinct inputs to one
/// fingerprint.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SourceFingerprint(Fingerprint);

impl SourceFingerprint {
    /// Fingerprint a source batch.
    pub fn of(batch: &SourceBatch) -> Result<SourceFingerprint> {
        if batch.items().iter().any(|item| !item.is_finite()) {
            return Err(Error::NonFinite {
                context: "source batch fingerprint",
            });
        }
        Ok(Self(Fingerprint::of(batch)?))
    }

    /// The underlying fingerprint.
    pub fn fingerprint(&self) -> Fingerprint {
        self.0
    }
}

impl std::fmt::Display for SourceFingerprint {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        self.0.fmt(f)
    }
}

/// A monotonically increasing sidecar schema version.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SchemaVersion(pub u32);

impl std::fmt::Display for SchemaVersion {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "v{}", self.0)
    }
}

/// A versioned persistence envelope for sidecar metadata.
///
/// Every sidecar helena writes carries its schema version explicitly;
/// [`decode_slice`](Self::decode_slice) checks the version *before*
/// interpreting the payload, so "an old blob silently reinterpreted under
/// new semantics" is a named error, not a serde-default guess.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SidecarEnvelope<M> {
    /// The schema this payload was written under.
    pub schema: SchemaVersion,
    /// The payload.
    pub meta: M,
}

impl<M: Serialize> SidecarEnvelope<M> {
    /// Wrap and encode a payload under a schema version.
    pub fn encode(schema: SchemaVersion, meta: &M) -> Result<Vec<u8>>
    where
        M: Sized,
    {
        #[derive(Serialize)]
        struct Borrowed<'a, M> {
            schema: SchemaVersion,
            meta: &'a M,
        }
        Ok(serde_json::to_vec_pretty(&Borrowed { schema, meta })?)
    }
}

impl<M: DeserializeOwned> SidecarEnvelope<M> {
    /// Decode a sidecar, demanding the expected schema version.
    ///
    /// Returns [`Error::Checkpoint`] naming both versions on a mismatch —
    /// migration is an explicit act, never an implicit default.
    pub fn decode_slice(bytes: &[u8], expected: SchemaVersion) -> Result<M> {
        #[derive(Deserialize)]
        struct VersionOnly {
            schema: Option<SchemaVersion>,
        }
        let version: VersionOnly = serde_json::from_slice(bytes)?;
        match version.schema {
            Some(found) if found == expected => {
                let envelope: SidecarEnvelope<M> = serde_json::from_slice(bytes)?;
                Ok(envelope.meta)
            }
            Some(found) => Err(Error::Checkpoint(format!(
                "sidecar schema {found} does not match expected {expected}; migrate explicitly"
            ))),
            None => Err(Error::Checkpoint(format!(
                "sidecar carries no schema version (expected {expected})"
            ))),
        }
    }
}

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

    #[test]
    fn fingerprint_displays_and_parses() {
        let fp = Fingerprint::of(&"hello").unwrap();
        let shown = fp.to_string();
        assert!(shown.starts_with("sha256:"));
        assert_eq!(shown.len(), 7 + 64);
        assert_eq!(shown.parse::<Fingerprint>().unwrap(), fp);
        assert!("sha256:short".parse::<Fingerprint>().is_err());
        assert!("md5:0000".parse::<Fingerprint>().is_err());
    }

    #[test]
    fn of_is_deterministic_and_value_sensitive() {
        let a = Fingerprint::of(&[1u32, 2, 3]).unwrap();
        let b = Fingerprint::of(&[1u32, 2, 3]).unwrap();
        let c = Fingerprint::of(&[1u32, 2, 4]).unwrap();
        assert_eq!(a, b);
        assert_ne!(a, c);
    }

    #[test]
    fn combine_is_order_and_length_sensitive() {
        let a = Fingerprint::of(&1u8).unwrap();
        let b = Fingerprint::of(&2u8).unwrap();
        assert_ne!(Fingerprint::combine(&[a, b]), Fingerprint::combine(&[b, a]));
        assert_ne!(Fingerprint::combine(&[a]), Fingerprint::combine(&[a, a]));
    }

    #[test]
    fn source_fingerprint_rejects_non_finite() {
        let good = SourceBatch::new(vec![Tensor::vector([0.5, -0.5])]);
        assert!(SourceFingerprint::of(&good).is_ok());
        let bad = SourceBatch::new(vec![Tensor::vector([f32::NAN])]);
        assert!(matches!(
            SourceFingerprint::of(&bad),
            Err(Error::NonFinite { .. })
        ));
    }

    #[test]
    fn envelope_round_trips_and_gates_versions() {
        #[derive(Debug, PartialEq, Serialize, serde::Deserialize)]
        struct Meta {
            name: String,
        }
        let meta = Meta { name: "run".into() };
        let bytes = SidecarEnvelope::encode(SchemaVersion(2), &meta).unwrap();

        let decoded: Meta = SidecarEnvelope::decode_slice(&bytes, SchemaVersion(2)).unwrap();
        assert_eq!(decoded, meta);

        let err = SidecarEnvelope::<Meta>::decode_slice(&bytes, SchemaVersion(3)).unwrap_err();
        assert!(matches!(err, Error::Checkpoint(_)));

        let unversioned = br#"{"meta":{"name":"run"}}"#;
        assert!(SidecarEnvelope::<Meta>::decode_slice(unversioned, SchemaVersion(2)).is_err());
    }

    #[test]
    fn sha256_hex_matches_known_vector() {
        assert_eq!(
            sha256_hex(b""),
            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }
}