Skip to main content

sim/runtime/watch/
proofs.rs

1use std::rc::Rc;
2
3use sim_kernel::{Error, Expr, Result, Symbol};
4use sim_lib_stream_device::{DeviceSample, ModeledSource, seq_is_monotone};
5use sim_lib_stream_wrist::{ModeledHeartRateSource, WornEvent, WornSensor};
6use sim_lib_view_device::{
7    DeviceProfile, DeviceProfileParts, DeviceSampleStore, EdgeId, EncodedScene, FrameClock,
8    LocalAdapter, RateClass, StoreKey,
9};
10use sim_lib_view_wrist::{
11    FleetSensorQuorum, FleetSensorSample, WatchCommand, WristSide, fleet_sensor_quorum, offer_worn,
12    store_worn_sample, sweep_watch_privacy, tick_worn, watch_adapter_loop, watch_frame_clock_at,
13    watch_glance_adapter, worn_state_from,
14};
15use sim_value::build;
16
17/// Result of the hardware-free glance pager proof.
18#[derive(Clone, Debug, PartialEq)]
19pub struct GlancePagerProof {
20    /// Whether the source is deterministic and monotone.
21    pub modeled_source_monotone: bool,
22    /// Whether the adapted glance becomes a watch notification command.
23    pub notification_sent: bool,
24    /// Modeled worn sample sequence.
25    pub sample_seq: u64,
26    /// Compact watch adapter cell budget.
27    pub adapter_cells: u8,
28    /// Number of notification body lines.
29    pub notification_lines: usize,
30    /// Encoded notification command.
31    pub notification: Expr,
32}
33
34impl GlancePagerProof {
35    /// Encodes the proof as expression data for cookbook recipes.
36    pub fn to_expr(&self) -> Expr {
37        build::map(vec![
38            ("kind", build::qsym("watch/sdk", "glance-pager-proof")),
39            (
40                "modeled-source-monotone",
41                Expr::Bool(self.modeled_source_monotone),
42            ),
43            ("notification-sent", Expr::Bool(self.notification_sent)),
44            ("sample-seq", build::uint(self.sample_seq)),
45            ("adapter-cells", build::uint(u64::from(self.adapter_cells))),
46            (
47                "notification-lines",
48                build::uint(self.notification_lines as u64),
49            ),
50            ("notification", self.notification.clone()),
51        ])
52    }
53}
54
55/// Runs the modeled source -> shared glance adapter -> notification proof.
56pub fn prove_glance_pager() -> Result<GlancePagerProof> {
57    let source = ModeledHeartRateSource;
58    let sample = source.at(14);
59    let profile = watch_profile();
60    let card = glance_card("Wrist", "HR", format!("{} bpm", heart_rate_bpm(&sample)?));
61    let encoded = EncodedScene::new(card);
62    let adapted =
63        watch_glance_adapter(false).adapt(&encoded, &worn_state_from(&sample), &profile)?;
64    let command = WatchCommand::notify_from_glance(adapted.as_ref())?;
65    let (notification_sent, notification_lines) = match &command {
66        WatchCommand::Notify { lines, .. } => (true, lines.len()),
67        _ => (false, 0),
68    };
69    Ok(GlancePagerProof {
70        modeled_source_monotone: seq_is_monotone(&source, 0, 4),
71        notification_sent,
72        sample_seq: sample.seq(),
73        adapter_cells: watch_glance_adapter(false).budget.cells,
74        notification_lines,
75        notification: command.to_expr(),
76    })
77}
78
79/// Result of the hardware-free hold-last proof.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct HoldLastProof {
82    /// Whether a stale frame reuses the last emitted card.
83    pub held_last: bool,
84    /// Number of coalesced worn updates reported as drops.
85    pub dropped: u32,
86    /// Whether the final frame is marked stale.
87    pub stale: bool,
88    /// Modeled sequence of the held frame.
89    pub held_seq: u64,
90}
91
92impl HoldLastProof {
93    /// Encodes the proof as expression data for cookbook recipes.
94    pub fn to_expr(&self) -> Expr {
95        build::map(vec![
96            ("kind", build::qsym("watch/sdk", "hold-last-proof")),
97            ("held-last", Expr::Bool(self.held_last)),
98            ("dropped", build::uint(u64::from(self.dropped))),
99            ("stale", Expr::Bool(self.stale)),
100            ("held-seq", build::uint(self.held_seq)),
101        ])
102    }
103}
104
105/// Runs the modeled hold-last staleness proof.
106pub fn prove_hold_last() -> Result<HoldLastProof> {
107    let profile = watch_profile();
108    let sample = ModeledHeartRateSource.at(0);
109    let encoded = EncodedScene::new(glance_card("Wrist", "HR", "58 bpm"));
110    let mut loop_ = watch_adapter_loop(&profile);
111
112    offer_worn(&mut loop_, &sample);
113    let fresh = tick_worn(
114        &mut loop_,
115        &watch_frame_clock_at(&profile, sample.seq()),
116        &encoded,
117        1,
118        &sample,
119        &profile,
120    )?;
121
122    for _ in 0..3 {
123        offer_worn(&mut loop_, &sample);
124    }
125    let stale = tick_worn(
126        &mut loop_,
127        &watch_frame_clock_at(&profile, 10),
128        &encoded,
129        1,
130        &sample,
131        &profile,
132    )?;
133
134    Ok(HoldLastProof {
135        held_last: Rc::ptr_eq(&fresh.out, &stale.out),
136        dropped: stale.dropped,
137        stale: stale.stale,
138        held_seq: stale.seq,
139    })
140}
141
142/// Result of the hardware-free privacy reaper proof.
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct PrivacyReaperProof {
145    /// Whether the HR sample is evicted after the privacy window.
146    pub hr_evicted: bool,
147    /// Whether the location sample is evicted after the privacy window.
148    pub location_evicted: bool,
149    /// Whether referenced content is evicted with the sensitive samples.
150    pub content_evicted: bool,
151    /// Number of records evicted by the final sweep.
152    pub evicted: usize,
153}
154
155impl PrivacyReaperProof {
156    /// Encodes the proof as expression data for cookbook recipes.
157    pub fn to_expr(&self) -> Expr {
158        build::map(vec![
159            ("kind", build::qsym("watch/sdk", "privacy-reaper-proof")),
160            ("hr-evicted", Expr::Bool(self.hr_evicted)),
161            ("location-evicted", Expr::Bool(self.location_evicted)),
162            ("content-evicted", Expr::Bool(self.content_evicted)),
163            ("evicted", build::uint(self.evicted as u64)),
164        ])
165    }
166}
167
168/// Runs the modeled privacy-window retention proof.
169pub fn prove_privacy_reaper() -> Result<PrivacyReaperProof> {
170    let profile = watch_profile();
171    let receipt = WatchCommand::PrivacyMode {
172        enabled: true,
173        window_ms: 1_000,
174    }
175    .privacy_consent_receipt(EdgeId::named("watch-sdk-modeled"), 21)
176    .ok_or_else(|| Error::Eval("enabled privacy command must yield a receipt".to_owned()))?;
177    let mut store = DeviceSampleStore::new();
178    let hr_content = StoreKey::named("watch-hr-content");
179    let location_content = StoreKey::named("watch-location-content");
180    store.insert_content(hr_content.clone(), build::text("heart-rate payload"));
181    store.insert_content(location_content.clone(), build::text("location payload"));
182
183    let hr = WornEvent::heart_rate(0, 72)?.to_expr();
184    let location = WornEvent::gps(1, 59_329_300, 18_068_600, 450)?.to_expr();
185    let hr_key = store_worn_sample(
186        &mut store,
187        &hr,
188        &receipt,
189        FrameClock::new(0, profile.rate),
190        vec![hr_content.clone()],
191    )?;
192    let location_key = store_worn_sample(
193        &mut store,
194        &location,
195        &receipt,
196        FrameClock::new(0, profile.rate),
197        vec![location_content.clone()],
198    )?;
199
200    let _kept = sweep_watch_privacy(
201        &mut store,
202        std::slice::from_ref(&receipt),
203        FrameClock::new(0, profile.rate),
204    );
205    let evicted = sweep_watch_privacy(
206        &mut store,
207        std::slice::from_ref(&receipt),
208        FrameClock::new(2, profile.rate),
209    );
210
211    Ok(PrivacyReaperProof {
212        hr_evicted: !store.contains_sample(&hr_key),
213        location_evicted: !store.contains_sample(&location_key),
214        content_evicted: !store.contains_content(&hr_content)
215            && !store.contains_content(&location_content),
216        evicted: evicted.len(),
217    })
218}
219
220/// Result of the hardware-free dual-watch quorum proof.
221#[derive(Clone, Debug, PartialEq, Eq)]
222pub struct DualQuorumProof {
223    /// Whether the divergent pair lowers confidence.
224    pub low_confidence: bool,
225    /// Quorum confidence in ten-thousandths.
226    pub confidence: u16,
227    /// Preferred side after scoring.
228    pub prefer: Symbol,
229    /// Absolute heart-rate disagreement.
230    pub delta_bpm: u64,
231}
232
233impl DualQuorumProof {
234    /// Encodes the proof as expression data for cookbook recipes.
235    pub fn to_expr(&self) -> Expr {
236        build::map(vec![
237            ("kind", build::qsym("watch/sdk", "dual-quorum-proof")),
238            ("low-confidence", Expr::Bool(self.low_confidence)),
239            ("confidence", build::uint(u64::from(self.confidence))),
240            ("prefer", Expr::Symbol(self.prefer.clone())),
241            ("delta-bpm", build::uint(self.delta_bpm)),
242        ])
243    }
244}
245
246/// Runs the modeled dual-watch heart-rate quorum proof.
247pub fn prove_dual_quorum() -> Result<DualQuorumProof> {
248    let sensor = WornSensor::HeartRate.symbol();
249    let left = FleetSensorSample::new(WristSide::Left, sensor.clone(), 72, 9_600)?;
250    let right = FleetSensorSample::new(WristSide::Right, sensor, 94, 8_800)?;
251    match fleet_sensor_quorum(&left, &right, 5)? {
252        FleetSensorQuorum::LowConfidence {
253            prefer,
254            delta,
255            confidence,
256            ..
257        } => Ok(DualQuorumProof {
258            low_confidence: true,
259            confidence,
260            prefer: side_symbol(prefer),
261            delta_bpm: delta,
262        }),
263        FleetSensorQuorum::Agree { confidence, .. } => Ok(DualQuorumProof {
264            low_confidence: false,
265            confidence,
266            prefer: Symbol::qualified("watch/side", "none"),
267            delta_bpm: 0,
268        }),
269    }
270}
271
272fn watch_profile() -> DeviceProfile {
273    DeviceProfile::new(DeviceProfileParts {
274        kind: Symbol::qualified("device", "watch-glance"),
275        display: symbols(&["round"]),
276        input: symbols(&["tap"]),
277        output: symbols(&["haptic", "notification"]),
278        links: symbols(&["modeled"]),
279        streams: vec![WornSensor::HeartRate.symbol(), WornSensor::Gps.symbol()],
280        rate: RateClass::watch(),
281        policy: build::map(vec![
282            ("consent", build::sym("visible")),
283            ("retention-ms", build::uint(1_000)),
284        ]),
285    })
286}
287
288fn glance_card(title: &str, label: &str, value: impl Into<String>) -> Expr {
289    build::map(vec![
290        ("kind", build::qsym("scene", "glance")),
291        ("title", build::text(title)),
292        ("urgency", build::sym("info")),
293        ("cells", build::uint(3)),
294        ("bypass-budget", Expr::Bool(false)),
295        (
296            "metric",
297            build::map(vec![
298                ("label", build::text(label)),
299                ("value", build::text(value)),
300            ]),
301        ),
302        (
303            "action",
304            build::map(vec![
305                ("label", build::text("ack")),
306                ("target", build::qsym("watch/action", "ack")),
307            ]),
308        ),
309    ])
310}
311
312fn heart_rate_bpm(event: &WornEvent) -> Result<u16> {
313    let value = sim_value::access::required(
314        event.payload(),
315        "beats-per-minute",
316        "watch heart-rate payload",
317    )?;
318    let Expr::Number(number) = value else {
319        return Err(Error::TypeMismatch {
320            expected: "heart-rate number",
321            found: "non-number",
322        });
323    };
324    number
325        .canonical
326        .parse()
327        .map_err(|err| Error::Eval(format!("invalid modeled heart rate: {err}")))
328}
329
330fn symbols(names: &[&str]) -> Vec<Symbol> {
331    names.iter().map(|name| Symbol::new(*name)).collect()
332}
333
334fn side_symbol(side: WristSide) -> Symbol {
335    match side {
336        WristSide::Left => Symbol::qualified("watch/side", "left"),
337        WristSide::Right => Symbol::qualified("watch/side", "right"),
338    }
339}