helena 0.1.0

Core types and component interfaces for helena, a latent data-to-waveform generation platform.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
//! Dataset manifests: raw documents to validated, path-confined manifests.
//!
//! A manifest enumerates the source items of a dataset and, for paired
//! training, their aligned target audio (PRD §18.1). This module is the
//! raw→validated typestate for that document (refoundation A5): deserialization
//! lands *only* in [`RawDatasetManifest`], whose sole exit is the validating
//! constructor [`RawDatasetManifest::validate`]. There is no other way to
//! obtain a [`Manifest`], so a consumer cannot be handed an unchecked document
//! — the old "forgot to call `validate()`" hole is closed by type, not by
//! discipline.
//!
//! What the two tiers separate:
//!
//! - [`RawDatasetManifest`] / [`RawDatasetItem`] are plain serde data with
//!   `#[serde(deny_unknown_fields)]` — a typo'd or misplaced field is a parse
//!   error, so the CLI no longer needs a mirrored strict-config schema to gain
//!   the same rejection. Fields are public; the type carries no invariants.
//! - [`Manifest`] / [`ManifestItem`] speak the domain vocabulary: a
//!   [`SampleRate`] (never a raw `u32` that could be zero), a [`SourceKind`]
//!   enum (never a stringly `source_type`), and [`ItemPath`]-typed paths whose
//!   confinement invariant is carried by the type. Fields are private; the
//!   accessors hand back proven values.
//!
//! [`ItemPath`] is where path confinement becomes structural: a value of that
//! type is provably relative and non-escaping, and [`ItemPath::join`] is the
//! only filesystem-facing operation this module exports — nothing else can turn
//! a manifest string into a `PathBuf`.

use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::error::{Error, Result};
use crate::latent::Metadata;
use crate::time::SampleRate;

/// The source modality of a dataset (PRD §14.1).
///
/// Parsed once, here, from the manifest's `source_type` field; threaded as this
/// enum everywhere thereafter, replacing the stringly `source_type: String`
/// flow that was re-reconciled per consumer. `#[non_exhaustive]`, so a future
/// modality is an additive change and downstream matches keep a wildcard arm.
///
/// The serde representation matches the historical strings exactly
/// (`"tabular"`, `"time_series"`, `"embeddings"`); an unknown modality is a
/// parse error, not a silent fallback.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SourceKind {
    /// One feature row per item.
    Tabular,
    /// Equal-length rows laid out as `[steps, channels]`.
    TimeSeries,
    /// One embedding vector per item.
    Embeddings,
}

impl std::fmt::Display for SourceKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let s = match self {
            SourceKind::Tabular => "tabular",
            SourceKind::TimeSeries => "time_series",
            SourceKind::Embeddings => "embeddings",
        };
        f.write_str(s)
    }
}

/// A validated, root-relative, non-escaping item path.
///
/// The confinement invariant of PRD §18.1 as a type: an `ItemPath` is provably
/// relative, contains at least one real path component, and has no
/// parent-directory (`..`), root, or drive-prefix component — so a manifest
/// cannot name a file outside the dataset root a caller selects. `./`
/// (current-directory) components are permitted and ignored. The invariant is
/// checked once, by [`new`](Self::new), and thereafter carried by the type;
/// [`join`](Self::join) is the only operation that touches the filesystem
/// namespace.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct ItemPath(PathBuf);

impl ItemPath {
    /// Validate a manifest path string into a confined [`ItemPath`].
    ///
    /// Returns [`Error::Validation`] if the path is empty, absolute, carries a
    /// drive prefix, contains a `..` component, or reduces to no real component
    /// (e.g. `"."` alone).
    pub fn new(path: impl Into<String>) -> Result<Self> {
        let path = PathBuf::from(path.into());
        if path.as_os_str().is_empty() {
            return Err(Error::validation("item path must not be empty"));
        }
        let mut saw_normal = false;
        for component in path.components() {
            match component {
                Component::Normal(_) => saw_normal = true,
                Component::CurDir => {}
                Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
                    return Err(Self::escape_error(&path));
                }
            }
        }
        if !saw_normal {
            return Err(Self::escape_error(&path));
        }
        Ok(Self(path))
    }

    /// Resolve this path under `root`. The only filesystem-facing operation the
    /// manifest module exports; safe because the invariant guarantees the join
    /// stays under `root`.
    pub fn join(&self, root: &Path) -> PathBuf {
        root.join(&self.0)
    }

    /// The confined path itself, relative to an unspecified root.
    pub fn as_path(&self) -> &Path {
        &self.0
    }

    fn escape_error(path: &Path) -> Error {
        Error::validation(format!(
            "path must be root-relative without parent-directory escape: {}",
            path.display()
        ))
    }
}

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

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

    fn try_from(path: String) -> Result<Self> {
        Self::new(path)
    }
}

impl<'de> Deserialize<'de> for ItemPath {
    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        // Deserialize as a string then route through the validating gate, so a
        // hand-built manifest with an escaping path fails closed at decode.
        let raw = String::deserialize(deserializer)?;
        ItemPath::new(raw).map_err(serde::de::Error::custom)
    }
}

/// A raw dataset manifest as deserialized, before validation.
///
/// `#[serde(deny_unknown_fields)]`: an unexpected key is a parse error, which
/// is what let the CLI drop its mirrored strict schema. This type is plain
/// data — public fields, no invariants — and its only meaningful exit is
/// [`validate`](Self::validate).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawDatasetManifest {
    /// Stable identifier for the dataset.
    pub dataset_id: String,
    /// Dataset version string.
    pub version: String,
    /// Source modality (parsed to a [`SourceKind`] on validation).
    pub source_type: SourceKind,
    /// Whether items carry aligned target audio (PRD §14.1).
    #[serde(default)]
    pub paired_audio: bool,
    /// Audio sample rate the items are recorded at, in hertz (validated
    /// nonzero on the way to [`Manifest`]).
    pub sample_rate: u32,
    /// The source items in the dataset.
    #[serde(default)]
    pub items: Vec<RawDatasetItem>,
}

/// One raw source item, optionally paired with target audio.
///
/// `paths` are validated into [`ItemPath`]s at deserialization (the serde gate
/// on [`ItemPath`]), so an escaping path fails before validation even runs;
/// `validate` then proves the cross-field invariants (unique ids, paired-audio
/// presence, positive durations).
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RawDatasetItem {
    /// Item id, unique within the dataset.
    pub id: String,
    /// Confined path to the source data for this item.
    pub source_path: ItemPath,
    /// Confined path to the aligned target audio, when paired.
    #[serde(default)]
    pub audio_path: Option<ItemPath>,
    /// Clip duration in seconds, when known.
    #[serde(default)]
    pub duration_seconds: Option<f32>,
    /// Free-form provenance, alignment info, and labels. Stays untyped: the
    /// manifest boundary does not interpret it.
    #[serde(default)]
    pub metadata: Metadata,
}

impl RawDatasetManifest {
    /// Parse a manifest from JSON bytes.
    ///
    /// Proves the *document shape* only — the field set, their types, and the
    /// [`ItemPath`] confinement gate. Dataset invariants (a nonzero sample
    /// rate, non-empty and unique ids, paired items carrying audio, positive
    /// declared durations) are [`validate`](Self::validate)'s responsibility. A
    /// parse failure is an [`Error::Json`]; a semantic failure is an
    /// [`Error::Validation`].
    pub fn from_json_slice(bytes: &[u8]) -> Result<Self> {
        Ok(serde_json::from_slice(bytes)?)
    }

    /// Validate structural invariants and produce a [`Manifest`] (PRD FR-004).
    ///
    /// The only path from a raw document to the validated type. Checks: a
    /// nonzero [`SampleRate`], non-empty and unique item ids, an `audio_path`
    /// on every item when the dataset declares `paired_audio`, and strictly
    /// positive declared durations. Consumes `self` — a validated manifest
    /// supersedes the raw document. Returns [`Error::Validation`] describing
    /// the first problem found.
    pub fn validate(self) -> Result<Manifest> {
        let sample_rate = SampleRate::try_from(self.sample_rate)?;

        let mut seen = HashSet::with_capacity(self.items.len());
        let mut items = Vec::with_capacity(self.items.len());
        for item in self.items {
            if item.id.trim().is_empty() {
                return Err(Error::validation("dataset item has an empty id"));
            }
            if !seen.insert(item.id.clone()) {
                return Err(Error::validation(format!("duplicate item id: {}", item.id)));
            }
            if self.paired_audio && item.audio_path.is_none() {
                return Err(Error::validation(format!(
                    "item {} is missing an audio_path but the dataset is paired",
                    item.id
                )));
            }
            let duration = match item.duration_seconds {
                Some(d) if d <= 0.0 || !d.is_finite() => {
                    return Err(Error::validation(format!(
                        "item {} has non-positive duration {d}",
                        item.id
                    )));
                }
                other => other,
            };
            items.push(ManifestItem {
                id: item.id,
                source_path: item.source_path,
                audio_path: item.audio_path,
                duration_seconds: duration,
                metadata: item.metadata,
            });
        }

        Ok(Manifest {
            dataset_id: self.dataset_id,
            version: self.version,
            source_type: self.source_type,
            paired_audio: self.paired_audio,
            sample_rate,
            items,
        })
    }
}

/// A validated dataset manifest.
///
/// Obtainable only through [`RawDatasetManifest::validate`], so holding one is
/// proof its invariants hold: a nonzero [`SampleRate`], unique non-empty ids,
/// paired items carrying audio, and confined paths. Fields are private; the
/// accessors return the proven values. Item order is *ordered evidence* — the
/// manifest order is preserved so training, evaluation, and report rows join
/// without re-sorting.
#[derive(Clone, Debug, Serialize)]
pub struct Manifest {
    dataset_id: String,
    version: String,
    source_type: SourceKind,
    paired_audio: bool,
    sample_rate: SampleRate,
    items: Vec<ManifestItem>,
}

/// One validated source item.
///
/// Constructed only as part of [`RawDatasetManifest::validate`]; its paths are
/// [`ItemPath`]s (confined) and its declared duration, if present, is strictly
/// positive and finite.
#[derive(Clone, Debug, Serialize)]
pub struct ManifestItem {
    id: String,
    source_path: ItemPath,
    audio_path: Option<ItemPath>,
    duration_seconds: Option<f32>,
    metadata: Metadata,
}

impl Manifest {
    /// Stable identifier for the dataset.
    pub fn dataset_id(&self) -> &str {
        &self.dataset_id
    }

    /// Dataset version string.
    pub fn version(&self) -> &str {
        &self.version
    }

    /// The source modality, parsed once.
    pub fn source_type(&self) -> SourceKind {
        self.source_type
    }

    /// Whether items carry aligned target audio.
    pub fn paired_audio(&self) -> bool {
        self.paired_audio
    }

    /// The validated (nonzero) sample rate.
    pub fn sample_rate(&self) -> SampleRate {
        self.sample_rate
    }

    /// The items, in preserved manifest order (ordered evidence).
    pub fn items(&self) -> &[ManifestItem] {
        &self.items
    }
}

impl ManifestItem {
    /// Item id, unique within the dataset.
    pub fn id(&self) -> &str {
        &self.id
    }

    /// The confined source path.
    pub fn source_path(&self) -> &ItemPath {
        &self.source_path
    }

    /// The confined paired-audio path, if this item is paired.
    pub fn audio_path(&self) -> Option<&ItemPath> {
        self.audio_path.as_ref()
    }

    /// The declared clip duration in seconds (strictly positive), if known.
    pub fn duration_seconds(&self) -> Option<f32> {
        self.duration_seconds
    }

    /// Free-form provenance, alignment info, and labels.
    pub fn metadata(&self) -> &Metadata {
        &self.metadata
    }
}

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

    #[test]
    fn parses_prd_example() {
        let json = r#"{
            "dataset_id": "sensor_texture_demo_v1",
            "version": "1.0.0",
            "source_type": "time_series",
            "paired_audio": true,
            "sample_rate": 44100,
            "items": [
                {
                    "id": "item_0001",
                    "source_path": "source/item_0001.parquet",
                    "audio_path": "audio/item_0001.wav",
                    "duration_seconds": 10.0,
                    "metadata": { "class": "normal", "split": "train" }
                }
            ]
        }"#;
        let raw = RawDatasetManifest::from_json_slice(json.as_bytes()).expect("parse");
        assert_eq!(raw.source_type, SourceKind::TimeSeries);
        let m = raw.validate().expect("validate");
        assert_eq!(m.items().len(), 1);
        assert_eq!(m.items()[0].id(), "item_0001");
        assert!(m.paired_audio());
        assert_eq!(m.sample_rate().get(), 44_100);
        assert_eq!(m.source_type(), SourceKind::TimeSeries);
    }

    #[test]
    fn deny_unknown_fields_rejects_typos() {
        // A misplaced or misspelled key fails at parse — the property that let
        // the CLI drop its mirrored strict schema.
        let json = r#"{
            "dataset_id": "d", "version": "1.0.0", "source_type": "tabular",
            "sample_rate": 44100, "typo_field": true
        }"#;
        assert!(RawDatasetManifest::from_json_slice(json.as_bytes()).is_err());
    }

    #[test]
    fn source_kind_serde_matches_historical_strings() {
        for (s, kind) in [
            ("tabular", SourceKind::Tabular),
            ("time_series", SourceKind::TimeSeries),
            ("embeddings", SourceKind::Embeddings),
        ] {
            let parsed: SourceKind = serde_json::from_str(&format!("\"{s}\"")).unwrap();
            assert_eq!(parsed, kind);
            assert_eq!(serde_json::to_string(&kind).unwrap(), format!("\"{s}\""));
        }
        // An unknown modality is a parse error, not a silent fallback.
        assert!(serde_json::from_str::<SourceKind>("\"midi\"").is_err());
    }

    fn valid_manifest() -> RawDatasetManifest {
        RawDatasetManifest {
            dataset_id: "d".into(),
            version: "1.0.0".into(),
            source_type: SourceKind::TimeSeries,
            paired_audio: true,
            sample_rate: 44_100,
            items: vec![RawDatasetItem {
                id: "item_0001".into(),
                source_path: ItemPath::new("source/item_0001.parquet").unwrap(),
                audio_path: Some(ItemPath::new("audio/item_0001.wav").unwrap()),
                duration_seconds: Some(10.0),
                metadata: Metadata::default(),
            }],
        }
    }

    #[test]
    fn validate_accepts_a_well_formed_manifest() {
        assert!(valid_manifest().validate().is_ok());
    }

    #[test]
    fn validate_rejects_zero_sample_rate() {
        let mut m = valid_manifest();
        m.sample_rate = 0;
        assert!(matches!(m.validate(), Err(Error::Validation(_))));
    }

    #[test]
    fn validate_rejects_paired_item_without_audio() {
        let mut m = valid_manifest();
        m.items[0].audio_path = None;
        assert!(matches!(m.validate(), Err(Error::Validation(_))));
    }

    #[test]
    fn validate_rejects_duplicate_ids() {
        let mut m = valid_manifest();
        let dup = m.items[0].clone();
        m.items.push(dup);
        assert!(matches!(m.validate(), Err(Error::Validation(_))));
    }

    #[test]
    fn validate_rejects_nonpositive_duration() {
        let mut m = valid_manifest();
        m.items[0].duration_seconds = Some(0.0);
        assert!(matches!(m.validate(), Err(Error::Validation(_))));
    }

    #[test]
    fn item_path_rejects_escapes() {
        // Parent-directory escape.
        assert!(ItemPath::new("../source.json").is_err());
        // A `..` buried mid-path.
        assert!(ItemPath::new("audio/../outside.wav").is_err());
        // Absolute path.
        assert!(ItemPath::new("/etc/passwd").is_err());
        // Empty.
        assert!(ItemPath::new("").is_err());
        // A bare current-directory reference reduces to no real component.
        assert!(ItemPath::new(".").is_err());
    }

    #[test]
    fn validate_rejects_source_path_escape() {
        // Escaping paths fail at the serde/`ItemPath` gate, before validate.
        let json = r#"{
            "dataset_id": "d", "version": "1.0.0", "source_type": "time_series",
            "paired_audio": false, "sample_rate": 44100,
            "items": [{ "id": "a", "source_path": "../source.json" }]
        }"#;
        assert!(RawDatasetManifest::from_json_slice(json.as_bytes()).is_err());

        // An absolute source path likewise fails the gate.
        let abs = std::env::current_dir().unwrap().join("source.json");
        let json = format!(
            r#"{{ "dataset_id": "d", "version": "1.0.0", "source_type": "time_series",
                 "paired_audio": false, "sample_rate": 44100,
                 "items": [{{ "id": "a", "source_path": {:?} }}] }}"#,
            abs.to_string_lossy()
        );
        assert!(RawDatasetManifest::from_json_slice(json.as_bytes()).is_err());
    }

    #[test]
    fn validate_rejects_audio_path_escape() {
        let json = r#"{
            "dataset_id": "d", "version": "1.0.0", "source_type": "time_series",
            "paired_audio": true, "sample_rate": 44100,
            "items": [{ "id": "a", "source_path": "source/a.json",
                        "audio_path": "audio/../outside.wav" }]
        }"#;
        assert!(RawDatasetManifest::from_json_slice(json.as_bytes()).is_err());
    }

    #[test]
    fn validate_accepts_current_directory_components() {
        let mut m = valid_manifest();
        m.items[0].source_path = ItemPath::new("./source/item.json").unwrap();
        m.items[0].audio_path = Some(ItemPath::new("audio/./item.wav").unwrap());
        assert!(m.validate().is_ok());
    }

    #[test]
    fn resolve_item_paths_are_root_relative() {
        let m = valid_manifest().validate().unwrap();
        let root = Path::new("/dataset");
        let item = &m.items()[0];
        assert_eq!(
            item.source_path().join(root),
            PathBuf::from("/dataset/source/item_0001.parquet")
        );
        assert_eq!(
            item.audio_path().unwrap().join(root),
            PathBuf::from("/dataset/audio/item_0001.wav")
        );
    }

    #[test]
    fn validate_allows_unpaired_item_without_audio() {
        // `paired_audio = false` lifts the audio-path requirement.
        let mut m = valid_manifest();
        m.paired_audio = false;
        m.items[0].audio_path = None;
        assert!(m.validate().is_ok());
    }
}