adam_web_ui/graph/bridge.rs
1//! Container-scoped lifecycle and approval protocol for the graph view.
2//!
3//! Start a fresh monotonically numbered generation before submitting requests.
4//! Disposing a generation is terminal; unknown generations never create models.
5//!
6//! ```
7//! use adam_web_ui::graph::BridgeState;
8//! let mut bridge = BridgeState::default();
9//! bridge.start(1)?;
10//! bridge.dispose(1);
11//! # Ok::<(), anyhow::Error>(())
12//! ```
13
14use anyhow::{Result, ensure};
15use cel_euclid::{Point2D, Rect, Size2D};
16use serde::{Deserialize, Serialize};
17
18use super::{ViewAction, ViewConstraints, ViewInput, ViewOutput};
19
20/// A geometry snapshot and source action with a generation-local ordering key.
21///
22/// Bounds use ordered finite minimum/maximum endpoints; viewport sizes are positive.
23///
24/// # Examples
25/// ```
26/// # use adam_web_ui::graph::{ViewRequest, ViewAction};
27/// let request = ViewRequest { generation: 1, revision: 1, sequence: 1,
28/// bounds: [0.0, 0.0, 200.0, 100.0], viewport: [400.0, 300.0],
29/// action: ViewAction::Fit };
30/// assert_eq!(request.sequence, 1);
31/// ```
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33pub struct ViewRequest {
34 /// The explicitly started lifecycle.
35 pub generation: u32,
36 /// The geometry revision measured by the renderer.
37 pub revision: u32,
38 /// The strictly increasing generation-local submission number.
39 pub sequence: u32,
40 /// Minimum x/y followed by maximum x/y.
41 pub bounds: [f64; 4],
42 /// Positive viewport width and height.
43 pub viewport: [f64; 2],
44 /// The requested source operation.
45 pub action: ViewAction,
46}
47
48/// Lifecycle and update messages for one mounted container channel.
49///
50/// # Examples
51/// ```
52/// use adam_web_ui::graph::ViewMessage;
53/// assert_eq!(serde_json::to_string(&ViewMessage::Start { generation: 1 })?,
54/// r#"{"kind":"start","generation":1}"#);
55/// # Ok::<(), serde_json::Error>(())
56/// ```
57#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum ViewMessage {
60 /// Explicitly creates the generation's single cached model.
61 Start {
62 /// The new strictly greater generation.
63 generation: u32,
64 },
65 /// Evaluates one complete snapshot.
66 Update {
67 /// The geometry, action and ordering identity.
68 request: ViewRequest,
69 },
70 /// Ends the matching active generation without affecting another generation.
71 Dispose {
72 /// The generation to retire.
73 generation: u32,
74 },
75}
76
77/// An approval or explicit current-request failure carrying the original identity.
78///
79/// # Examples
80/// ```
81/// use adam_web_ui::graph::ViewReply;
82/// let reply = ViewReply::Failed { generation: 1, revision: 1, sequence: 1,
83/// message: "invalid viewport".into() };
84/// assert!(serde_json::to_string(&reply)?.contains(r#""kind":"failed""#));
85/// # Ok::<(), serde_json::Error>(())
86/// ```
87#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
88#[serde(tag = "kind", rename_all = "snake_case")]
89pub enum ViewReply {
90 /// Contains only fully evaluated model output.
91 Accepted {
92 /// The request generation.
93 generation: u32,
94 /// The request geometry revision.
95 revision: u32,
96 /// The request submission sequence.
97 sequence: u32,
98 /// The approved view and model-owned gesture limits.
99 output: ViewOutput,
100 },
101 /// Reports invalid current geometry or a model propagation failure.
102 Failed {
103 /// The request generation.
104 generation: u32,
105 /// The request geometry revision.
106 revision: u32,
107 /// The request submission sequence.
108 sequence: u32,
109 /// The diagnostic to report visibly and through the host error channel.
110 message: String,
111 },
112}
113
114/// Owns one cached model and lifecycle/order watermarks for one container.
115///
116/// # Examples
117/// ```
118/// let mut bridge = adam_web_ui::graph::BridgeState::default();
119/// bridge.start(1)?;
120/// assert!(bridge.start(1).is_err());
121/// # Ok::<(), anyhow::Error>(())
122/// ```
123#[derive(Default)]
124pub struct BridgeState {
125 /// The greatest successfully claimed generation, including disposed generations.
126 generation: Option<u32>,
127 /// The active generation's model, absent after disposal or failed startup.
128 model: Option<ViewConstraints>,
129 /// The last consumed sequence and geometry revision.
130 processed: Option<(u32, u32)>,
131}
132
133impl BridgeState {
134 /// Decodes a wire message or returns a JavaScript diagnostic as an error.
135 ///
136 /// # Errors
137 /// Returns errors for diagnostic strings, malformed messages, or startup failure.
138 ///
139 /// - Complexity: O(n + p) time and space in message size `n` and model work `p`.
140 pub(crate) fn receive(&mut self, payload: serde_json::Value) -> Result<Option<ViewReply>> {
141 if let Some(message) = payload.as_str() {
142 anyhow::bail!("{message}");
143 }
144 self.process(serde_json::from_value(payload)?)
145 }
146
147 /// Dispatches a typed lifecycle message without framework-specific branching.
148 ///
149 /// # Errors
150 /// Returns startup errors; current update failures are explicit failed replies.
151 ///
152 /// - Complexity: O(p) time and space in model construction/propagation work.
153 ///
154 /// # Examples
155 /// ```
156 /// use adam_web_ui::graph::{BridgeState, ViewMessage};
157 /// assert!(BridgeState::default().process(ViewMessage::Start { generation: 1 })?.is_none());
158 /// # Ok::<(), anyhow::Error>(())
159 /// ```
160 pub fn process(&mut self, message: ViewMessage) -> Result<Option<ViewReply>> {
161 match message {
162 ViewMessage::Start { generation } => {
163 self.start(generation)?;
164 Ok(None)
165 }
166 ViewMessage::Update { request } => self.handle(request),
167 ViewMessage::Dispose { generation } => {
168 self.dispose(generation);
169 Ok(None)
170 }
171 }
172 }
173 /// Claims a strictly newer generation and parses its model exactly once.
174 ///
175 /// # Errors
176 /// Returns an error for a repeated/older generation or model initialization failure.
177 /// A failed initialization still retires the claimed generation.
178 ///
179 /// - Complexity: O(p) time and space for model initialization work `p`.
180 ///
181 /// # Examples
182 /// ```
183 /// let mut bridge = adam_web_ui::graph::BridgeState::default();
184 /// bridge.start(1)?;
185 /// # Ok::<(), anyhow::Error>(())
186 /// ```
187 pub fn start(&mut self, generation: u32) -> Result<()> {
188 ensure!(
189 self.generation.is_none_or(|last| generation > last),
190 "graph generation must increase monotonically"
191 );
192 self.generation = Some(generation);
193 self.processed = None;
194 self.model = None;
195 self.model = Some(ViewConstraints::new()?);
196 Ok(())
197 }
198
199 /// Evaluates current ordered work without implicitly starting a generation.
200 ///
201 /// Returns `None` only for inactive/unknown generations, consumed sequences,
202 /// or older geometry. Invalid current inputs produce a `Failed` reply.
203 /// Failed current requests consume their ordering identity too.
204 ///
205 /// # Errors
206 /// Reserves transport-level errors for the channel owner; model/input errors
207 /// are represented by `ViewReply::Failed`.
208 ///
209 /// - Complexity: O(p) time and space in cached model propagation work `p`.
210 ///
211 /// # Examples
212 /// ```
213 /// # use adam_web_ui::graph::{BridgeState, ViewAction, ViewRequest};
214 /// let mut bridge = BridgeState::default();
215 /// assert!(bridge.handle(ViewRequest { generation: 1, revision: 1, sequence: 1,
216 /// bounds: [0.0, 0.0, 1.0, 1.0], viewport: [100.0, 100.0],
217 /// action: ViewAction::Fit })?.is_none());
218 /// # Ok::<(), anyhow::Error>(())
219 /// ```
220 pub fn handle(&mut self, request: ViewRequest) -> Result<Option<ViewReply>> {
221 if self.generation != Some(request.generation) {
222 return Ok(None);
223 }
224 let Some(model) = self.model.as_mut() else {
225 return Ok(None);
226 };
227 if self.processed.is_some_and(|(sequence, revision)| {
228 request.sequence <= sequence || request.revision < revision
229 }) {
230 return Ok(None);
231 }
232 self.processed = Some((request.sequence, request.revision));
233 let [min_x, min_y, max_x, max_y] = request.bounds;
234 let result = (|| {
235 ensure!(
236 request.bounds.iter().all(|v| v.is_finite()) && min_x <= max_x && min_y <= max_y,
237 "invalid graph bounds"
238 );
239 model.update(ViewInput {
240 bounds: Rect::new(
241 Point2D::new(min_x, min_y),
242 Size2D::new(max_x - min_x, max_y - min_y),
243 ),
244 viewport: Size2D::new(request.viewport[0], request.viewport[1]),
245 action: request.action,
246 })
247 })();
248 Ok(Some(match result {
249 Ok(output) => ViewReply::Accepted {
250 generation: request.generation,
251 revision: request.revision,
252 sequence: request.sequence,
253 output,
254 },
255 Err(error) => ViewReply::Failed {
256 generation: request.generation,
257 revision: request.revision,
258 sequence: request.sequence,
259 message: error.to_string(),
260 },
261 }))
262 }
263
264 /// Retires only the matching generation, retaining its terminal watermark.
265 ///
266 /// # Examples
267 /// ```
268 /// let mut bridge = adam_web_ui::graph::BridgeState::default();
269 /// bridge.start(1)?;
270 /// bridge.dispose(1);
271 /// assert!(bridge.start(1).is_err());
272 /// # Ok::<(), anyhow::Error>(())
273 /// ```
274 pub fn dispose(&mut self, generation: u32) {
275 if self.generation == Some(generation) {
276 self.model = None;
277 }
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use crate::graph::{ViewAction, ViewTransform};
285
286 /// Builds a valid absolute proposal with fixed geometry.
287 fn request(generation: u32, sequence: u32) -> ViewRequest {
288 ViewRequest {
289 generation,
290 revision: 1,
291 sequence,
292 bounds: [0.0, 0.0, 200.0, 100.0],
293 viewport: [400.0, 300.0],
294 action: ViewAction::Propose {
295 view: ViewTransform {
296 x: 0.0,
297 y: 50.0,
298 k: 2.0,
299 },
300 },
301 }
302 }
303
304 /// Unknown generations never implicitly start a model.
305 #[test]
306 fn stale_generation_is_ignored() {
307 let mut state = BridgeState::default();
308 assert!(state.handle(request(1, 1)).unwrap().is_none());
309 state.start(2).unwrap();
310 assert!(state.handle(request(1, 1)).unwrap().is_none());
311 assert!(state.handle(request(3, 1)).unwrap().is_none());
312 }
313
314 /// Disposal is terminal even when a delayed Start arrives.
315 #[test]
316 fn disposed_generation_cannot_restart() {
317 let mut state = BridgeState::default();
318 state.start(1).unwrap();
319 state.dispose(1);
320 assert!(state.start(1).is_err());
321 assert!(state.handle(request(1, 1)).unwrap().is_none());
322 state.start(2).unwrap();
323 state.dispose(1);
324 assert!(state.handle(request(2, 1)).unwrap().is_some());
325 }
326
327 /// Old sequences and geometry never edit the retained source aspiration.
328 #[test]
329 fn stale_sequence_does_not_mutate_model() {
330 let mut state = BridgeState::default();
331 state.start(1).unwrap();
332 let first = state.handle(request(1, 2)).unwrap().unwrap();
333 let mut old = request(1, 1);
334 old.action = ViewAction::Fit;
335 assert!(state.handle(old).unwrap().is_none());
336 let mut old_geometry = request(1, 3);
337 old_geometry.revision = 0;
338 assert!(state.handle(old_geometry).unwrap().is_none());
339 let mut refresh = request(1, 4);
340 refresh.action = ViewAction::Refresh;
341 let Some(ViewReply::Accepted { output: actual, .. }) = state.handle(refresh).unwrap()
342 else {
343 panic!("refresh must be approved");
344 };
345 let ViewReply::Accepted {
346 output: expected, ..
347 } = first
348 else {
349 panic!("initial proposal must be approved");
350 };
351 assert_eq!(expected, actual);
352 }
353
354 /// Invalid current geometry returns a failure with the request identity.
355 #[test]
356 fn current_failure_is_a_failed_reply() {
357 let mut state = BridgeState::default();
358 state.start(1).unwrap();
359 let mut invalid = request(1, 1);
360 invalid.viewport[0] = 0.0;
361 assert!(matches!(
362 state.handle(invalid).unwrap(),
363 Some(ViewReply::Failed {
364 generation: 1,
365 sequence: 1,
366 ..
367 })
368 ));
369 }
370
371 /// Serde validates u32 fields rather than truncating hostile numbers.
372 #[test]
373 fn message_numbers_are_validated() {
374 for number in ["-1", "1.5", "4294967296"] {
375 assert!(
376 serde_json::from_str::<ViewMessage>(&format!(
377 r#"{{"kind":"start","generation":{number}}}"#
378 ))
379 .is_err()
380 );
381 }
382 }
383
384 /// Malformed current messages and JS diagnostics are errors, never stale success.
385 #[test]
386 fn wire_errors_are_explicit_and_do_not_consume_valid_work() {
387 let mut state = BridgeState::default();
388 state.start(1).unwrap();
389 assert!(
390 state
391 .receive(serde_json::json!({
392 "kind": "update", "request": { "generation": 1, "sequence": -1 }
393 }))
394 .is_err()
395 );
396 assert_eq!(
397 state
398 .receive(serde_json::json!("channel failed"))
399 .unwrap_err()
400 .to_string(),
401 "channel failed"
402 );
403 assert!(state.handle(request(1, 1)).unwrap().is_some());
404 }
405
406 /// Container owners retain independent models and sequence watermarks.
407 #[test]
408 fn two_containers_are_independent() {
409 let mut a = BridgeState::default();
410 let mut b = BridgeState::default();
411 a.start(1).unwrap();
412 b.start(1).unwrap();
413 a.dispose(1);
414 assert!(b.handle(request(1, 1)).unwrap().is_some());
415 assert!(a.handle(request(1, 1)).unwrap().is_none());
416 }
417
418 /// Shared fixtures encode the exact tags, field names, and action shapes.
419 #[test]
420 fn wire_fixtures_round_trip() {
421 let fixture: serde_json::Value =
422 serde_json::from_str(include_str!("../../../begin/js/graph-protocol.json")).unwrap();
423 let mut state = BridgeState::default();
424 for message in fixture["messages"].as_array().unwrap() {
425 let parsed: ViewMessage = serde_json::from_value(message.clone()).unwrap();
426 assert_eq!(serde_json::to_value(&parsed).unwrap(), *message);
427 assert!(state.process(parsed).is_ok());
428 }
429 for reply in fixture["replies"].as_array().unwrap() {
430 let parsed: ViewReply = serde_json::from_value(reply.clone()).unwrap();
431 assert_eq!(serde_json::to_value(parsed).unwrap(), *reply);
432 }
433 }
434}