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#[derive(Clone, Debug, PartialEq)]
19pub struct GlancePagerProof {
20 pub modeled_source_monotone: bool,
22 pub notification_sent: bool,
24 pub sample_seq: u64,
26 pub adapter_cells: u8,
28 pub notification_lines: usize,
30 pub notification: Expr,
32}
33
34impl GlancePagerProof {
35 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
55pub 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#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct HoldLastProof {
82 pub held_last: bool,
84 pub dropped: u32,
86 pub stale: bool,
88 pub held_seq: u64,
90}
91
92impl HoldLastProof {
93 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
105pub 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#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct PrivacyReaperProof {
145 pub hr_evicted: bool,
147 pub location_evicted: bool,
149 pub content_evicted: bool,
151 pub evicted: usize,
153}
154
155impl PrivacyReaperProof {
156 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
168pub 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#[derive(Clone, Debug, PartialEq, Eq)]
222pub struct DualQuorumProof {
223 pub low_confidence: bool,
225 pub confidence: u16,
227 pub prefer: Symbol,
229 pub delta_bpm: u64,
231}
232
233impl DualQuorumProof {
234 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
246pub 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}