helena 0.1.0

Core types and component interfaces for helena, a latent data-to-waveform generation platform.
Documentation
use super::*;
use crate::latent::KindTag;
use crate::manifest::SourceKind;

#[test]
fn config_roundtrips_through_json() {
    let json = r#"{
        "project": "helena",
        "experiment_name": "sensor_to_texture_v0",
        "sample_rate": 44100,
        "clip_duration_seconds": 10,
        "source_data": { "type": "time_series", "path": "data/source.parquet", "alignment": "paired" },
        "target_audio": { "path": "data/audio/", "segmentation": { "window_seconds": 10, "overlap_seconds": 2 } },
        "data_encoder": { "type": "temporal_transformer", "latent_dim": 256 },
        "audio_codec": { "type": "neural_codec", "latent_type": "continuous", "freeze": true },
        "conditional_generator": { "type": "token_transformer", "layers": 12, "hidden_dim": 768 },
        "training": { "batch_size": 16, "learning_rate": 0.0001, "max_steps": 100000, "seed": 42 }
    }"#;
    let cfg = RawExperimentConfig::from_json_str(json).expect("parse");
    assert_eq!(cfg.project, "helena");
    assert_eq!(cfg.source_data.kind, SourceKind::TimeSeries);
    assert_eq!(cfg.audio_codec.latent_type, KindTag::Continuous);
    assert!(cfg.audio_codec.freeze);
    assert_eq!(cfg.conditional_generator.extra.get("layers").unwrap(), 12);
    assert_eq!(cfg.generation.num_candidates, 1);

    // Resolution yields the domain-typed spec.
    let spec = cfg.resolve().expect("resolve");
    assert_eq!(spec.data_encoder().latent_dim().get(), 256);
    assert_eq!(spec.audio_codec().latent_kind(), KindTag::Continuous);
}

#[test]
fn config_parses_from_toml() {
    let toml = r#"
        project = "helena"
        experiment_name = "sensor_to_texture_v0"
        sample_rate = 44100
        clip_duration_seconds = 10.0

        [source_data]
        type = "time_series"
        path = "data/source.parquet"
        alignment = "paired"

        [target_audio]
        path = "data/audio/"
        [target_audio.segmentation]
        window_seconds = 10.0
        overlap_seconds = 2.0

        [data_encoder]
        type = "temporal_transformer"
        latent_dim = 256

        [audio_codec]
        type = "neural_codec"
        latent_type = "continuous"
        freeze = true

        [conditional_generator]
        type = "token_transformer"
        layers = 12

        [training]
        batch_size = 16
        learning_rate = 0.0001
        max_steps = 100000
        seed = 42
    "#;
    let cfg = RawExperimentConfig::from_toml_str(toml).expect("parse toml");
    assert_eq!(cfg.experiment_name, "sensor_to_texture_v0");
    assert_eq!(cfg.data_encoder.latent_dim, 256);
    assert_eq!(cfg.training.max_steps, 100_000);
    assert_eq!(cfg.conditional_generator.extra.get("layers").unwrap(), 12);
}

#[test]
fn from_toml_str_types_parse_failures_distinctly() {
    // Both malformed syntax and a missing required field are *parse* failures
    // (Error::Toml), surfaced before semantic validation ever runs. A well-formed
    // but semantically invalid config is instead an Error::Validation from
    // resolve(); keeping the two apart is the point of the typed variant.
    let malformed = RawExperimentConfig::from_toml_str("[unclosed").unwrap_err();
    assert!(
        matches!(malformed, crate::Error::Toml(_)),
        "got {malformed:?}"
    );

    let missing_field = RawExperimentConfig::from_toml_str("project = \"p\"").unwrap_err();
    assert!(
        matches!(missing_field, crate::Error::Toml(_)),
        "a missing required field is a parse failure, not validation: {missing_field:?}"
    );
}

#[test]
fn parse_and_resolve_are_separate_steps() {
    // A shape-valid document that is semantically invalid parses fine and fails
    // only at resolve — the raw→validated boundary the module exists to draw.
    let mut cfg = valid_config();
    cfg.sample_rate = 0;
    assert!(matches!(
        cfg.clone().resolve(),
        Err(crate::Error::Validation(_))
    ));
    // The raw document itself is untouched and still a well-formed value.
    assert_eq!(cfg.sample_rate, 0);
}

#[test]
fn deny_unknown_fields_rejects_top_level_typo() {
    // A stray top-level key fails at parse — what lets the CLI drop its mirrored
    // strict schema. (Component sections keep a flattened `extra`, so their
    // discipline is `reject_unknown_extra`, exercised in the extra tests.)
    let json = r#"{
        "project": "p", "experiment_name": "e", "sample_rate": 8,
        "clip_duration_seconds": 0.5,
        "source_data": { "type": "tabular", "path": "m.json" },
        "target_audio": { "path": "audio" },
        "data_encoder": { "type": "vector", "latent_dim": 2 },
        "audio_codec": { "type": "pcm", "latent_type": "continuous" },
        "conditional_generator": { "type": "mlp" },
        "training": { "batch_size": 1, "learning_rate": 0.001, "max_steps": 1 },
        "surprise": true
    }"#;
    assert!(RawExperimentConfig::from_json_str(json).is_err());
}

#[test]
fn legacy_rvq_latent_type_no_longer_parses() {
    // The wire-format break: `latent_type` is a KindTag now, so "rvq" is a
    // shape (parse) error, not a value that silently resolves.
    let json = r#"{
        "project": "p", "experiment_name": "e", "sample_rate": 8,
        "clip_duration_seconds": 0.5,
        "source_data": { "type": "tabular", "path": "m.json" },
        "target_audio": { "path": "audio" },
        "data_encoder": { "type": "vector", "latent_dim": 2 },
        "audio_codec": { "type": "pcm", "latent_type": "rvq" },
        "conditional_generator": { "type": "mlp" },
        "training": { "batch_size": 1, "learning_rate": 0.001, "max_steps": 1 }
    }"#;
    assert!(RawExperimentConfig::from_json_str(json).is_err());
}