Skip to main content

sensorlm/data/captioning/
semantic.rs

1//! Level-3 (semantic) caption generation.
2//!
3//! Produces high-level, human-readable descriptions of labelled **activities**,
4//! **sleep periods**, and **mood** events that occurred during the measurement
5//! window.
6//!
7//! # Caption structure
8//!
9//! ```text
10//! Walking was detected between minutes 480 and 540.
11//! Running occurred from minute 600 to 660.
12//! Sleep during minutes 0 to 440.
13//! The person logged their mood as calm at minute 300.
14//! ```
15//!
16//! # Reference correspondence
17//!
18//! This is a direct port of `captioning.py::generate_semantic_caption`.
19
20use rand::{seq::SliceRandom, Rng};
21
22use crate::data::captioning::templates::{ACTIVITY_TEMPLATES, MOOD_TEMPLATES};
23
24// ---------------------------------------------------------------------------
25// Public types
26// ---------------------------------------------------------------------------
27
28/// A labelled activity interval.
29#[derive(Debug, Clone)]
30pub struct ActivityEvent {
31    /// Activity name as it will appear in the caption (e.g. `"Walking"`).
32    pub name: String,
33    /// Start offset in minutes from midnight (0–1439).
34    pub start_minute: usize,
35    /// End offset in minutes from midnight (0–1439).
36    pub end_minute: usize,
37}
38
39impl ActivityEvent {
40    /// Duration of the event in minutes.
41    pub fn duration(&self) -> usize {
42        self.end_minute.saturating_sub(self.start_minute) + 1
43    }
44}
45
46/// A sleep interval.
47#[derive(Debug, Clone)]
48pub struct SleepEvent {
49    /// Start of sleep in minutes from midnight.
50    pub start_minute: usize,
51    /// End of sleep in minutes from midnight.
52    pub end_minute: usize,
53}
54
55/// A self-reported mood entry.
56#[derive(Debug, Clone)]
57pub struct MoodEvent {
58    /// Mood label, e.g. `"calm"`, `"stressed"`.
59    pub mood: String,
60    /// Time of report in minutes from midnight.
61    pub minute: usize,
62}
63
64// ---------------------------------------------------------------------------
65// Caption generator
66// ---------------------------------------------------------------------------
67
68/// Generate a level-3 semantic caption.
69///
70/// # Arguments
71///
72/// * `activities`           – Labelled activity events.
73/// * `sleep`                – Sleep interval events.
74/// * `moods`                – Mood log entries (may be empty).
75/// * `top_k_activity`       – Keep at most this many activities (by duration,
76///   shortest first so the longest — most significant — are included).
77/// * `top_k_sleep`          – Keep at most this many sleep periods.
78/// * `min_activity_duration`– Activities shorter than this many minutes are
79///   discarded (default: 20 min, matching reference).
80/// * `rng`                  – Random number generator for template selection.
81///
82/// # Returns
83///
84/// A multi-line string that combines the activity, sleep, and mood captions.
85pub fn generate_semantic_caption<R: Rng>(
86    activities: &[ActivityEvent],
87    sleep: &[SleepEvent],
88    moods: &[MoodEvent],
89    top_k_activity: usize,
90    top_k_sleep: usize,
91    min_activity_duration: usize,
92    rng: &mut R,
93) -> String {
94    let mut parts = Vec::<String>::new();
95
96    // -----------------------------------------------------------------------
97    // Activities
98    // -----------------------------------------------------------------------
99    let mut valid_acts: Vec<&ActivityEvent> = activities
100        .iter()
101        .filter(|a| a.duration() >= min_activity_duration)
102        .collect();
103
104    // Sort by duration ascending so the last `top_k_activity` are the longest.
105    valid_acts.sort_by_key(|a| a.duration());
106
107    // Keep top-k (the ones at the *end* of the sorted list are longest).
108    let selected_acts: Vec<&&ActivityEvent> = valid_acts
109        .iter()
110        .rev()
111        .take(top_k_activity)
112        .collect();
113
114    let mut act_sentences = Vec::new();
115    for &&act in &selected_acts {
116        act_sentences.push(describe_activity(&act.name, act.start_minute, act.end_minute, rng));
117    }
118    if !act_sentences.is_empty() {
119        parts.push(act_sentences.join(" "));
120    }
121
122    // -----------------------------------------------------------------------
123    // Sleep
124    // -----------------------------------------------------------------------
125    let mut sleep_sorted: Vec<&SleepEvent> = sleep.iter().collect();
126    sleep_sorted.sort_by_key(|s| s.end_minute.saturating_sub(s.start_minute));
127
128    let mut sleep_sentences = Vec::new();
129    for &s in sleep_sorted.iter().rev().take(top_k_sleep) {
130        sleep_sentences.push(describe_activity(
131            "Sleep",
132            s.start_minute,
133            s.end_minute,
134            rng,
135        ));
136    }
137    if !sleep_sentences.is_empty() {
138        parts.push(sleep_sentences.join(" "));
139    }
140
141    // -----------------------------------------------------------------------
142    // Mood
143    // -----------------------------------------------------------------------
144    for m in moods {
145        parts.push(describe_mood(&m.mood, m.minute, rng));
146    }
147
148    parts.join("\n")
149}
150
151// ---------------------------------------------------------------------------
152// Helpers
153// ---------------------------------------------------------------------------
154
155fn describe_activity<R: Rng>(
156    activity: &str,
157    start_minute: usize,
158    end_minute: usize,
159    rng: &mut R,
160) -> String {
161    let tmpl = ACTIVITY_TEMPLATES
162        .choose(rng)
163        .copied()
164        .unwrap_or(ACTIVITY_TEMPLATES[0]);
165    tmpl.replace("{activity}", activity)
166        .replace("{start_minute}", &start_minute.to_string())
167        .replace("{end_minute}", &end_minute.to_string())
168}
169
170fn describe_mood<R: Rng>(mood: &str, minute: usize, rng: &mut R) -> String {
171    let tmpl = MOOD_TEMPLATES
172        .choose(rng)
173        .copied()
174        .unwrap_or(MOOD_TEMPLATES[0]);
175    tmpl.replace("{mood}", mood)
176        .replace("{time}", &minute.to_string())
177}
178
179/// Parse a `"HH:MM:SS"` or ISO-8601 time string into minutes from midnight.
180///
181/// Returns `None` if parsing fails.
182pub fn parse_minutes(time_str: &str) -> Option<usize> {
183    // Try "HH:MM:SS"
184    let parts: Vec<&str> = time_str.trim_start_matches(|c: char| !c.is_ascii_digit())
185        .split(':')
186        .collect();
187    if parts.len() >= 2 {
188        let h: usize = parts[0].parse().ok()?;
189        let m: usize = parts[1].parse().ok()?;
190        return Some(h * 60 + m);
191    }
192    None
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use rand::{rngs::StdRng, SeedableRng};
199
200    #[test]
201    fn test_semantic_caption_basic() {
202        let mut rng = StdRng::seed_from_u64(1);
203        let activities = vec![
204            ActivityEvent { name: "Walking".into(), start_minute: 480, end_minute: 540 },
205            ActivityEvent { name: "Running".into(), start_minute: 600, end_minute: 660 },
206        ];
207        let sleep = vec![SleepEvent { start_minute: 0, end_minute: 440 }];
208        let moods = vec![MoodEvent { mood: "calm".into(), minute: 300 }];
209        let cap = generate_semantic_caption(&activities, &sleep, &moods, 8, 2, 20, &mut rng);
210        assert!(cap.contains("Walking") || cap.contains("Running"),
211            "Caption should mention activities: {cap}");
212        assert!(cap.contains("Sleep"), "Caption should mention sleep: {cap}");
213        assert!(cap.contains("calm"), "Caption should mention mood: {cap}");
214    }
215
216    #[test]
217    fn test_min_duration_filter() {
218        let mut rng = StdRng::seed_from_u64(2);
219        let activities = vec![
220            ActivityEvent { name: "Short".into(), start_minute: 0, end_minute: 10 }, // 11 min < 20
221            ActivityEvent { name: "Long".into(),  start_minute: 0, end_minute: 60 }, // 61 min > 20
222        ];
223        let cap = generate_semantic_caption(&activities, &[], &[], 8, 2, 20, &mut rng);
224        assert!(!cap.contains("Short"), "Short activity should be filtered");
225        assert!(cap.contains("Long"), "Long activity should appear");
226    }
227
228    #[test]
229    fn test_parse_minutes() {
230        assert_eq!(parse_minutes("08:30:00"), Some(510));
231        assert_eq!(parse_minutes("00:00:00"), Some(0));
232    }
233}