1use serde::{Deserialize, Serialize};
25
26pub const COMPONENT_EXCHANGE: &str = "exchange";
27pub const COMPONENT_QUEUE: &str = "queue";
28pub const COMPONENT_BACKLOG: &str = "backlog";
29pub const COMPONENT_STATUS: &str = "status";
30pub const COMPONENT_REVIEW: &str = "review";
31pub const COMPONENT_DONE: &str = "done";
32pub const COMPONENT_DONE_ARCHIVE: &str = "done_archive";
33pub const COMPONENT_GITLINK: &str = "gitlink";
34pub const COMPONENT_EDITOR_WRITEBACK: &str = "editor_writeback";
35
36pub const MANAGED_OUTPUT_COMPONENTS: &[&str] = &[
42 COMPONENT_EXCHANGE,
43 COMPONENT_BACKLOG,
44 COMPONENT_STATUS,
45 COMPONENT_REVIEW,
46 COMPONENT_DONE,
47 COMPONENT_DONE_ARCHIVE,
48 COMPONENT_GITLINK,
49 COMPONENT_EDITOR_WRITEBACK,
50];
51
52pub const MANAGED_CONFLICT_COMPONENTS: &[&str] = &[
57 COMPONENT_EXCHANGE,
58 COMPONENT_QUEUE,
59 COMPONENT_BACKLOG,
60 COMPONENT_STATUS,
61 COMPONENT_REVIEW,
62 COMPONENT_DONE,
63 COMPONENT_DONE_ARCHIVE,
64 COMPONENT_GITLINK,
65 COMPONENT_EDITOR_WRITEBACK,
66];
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct Address {
71 pub component: String,
73 pub occurrence: usize,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub node_key: Option<String>,
79}
80
81impl Address {
82 pub fn component(name: &str, occurrence: usize) -> Self {
84 Address {
85 component: name.to_string(),
86 occurrence,
87 node_key: None,
88 }
89 }
90
91 pub fn node(component: &str, occurrence: usize, node_key: &str) -> Self {
93 Address {
94 component: component.to_string(),
95 occurrence,
96 node_key: Some(node_key.to_string()),
97 }
98 }
99
100 pub fn from_component_node_key(component: &str, node_key: &str) -> Self {
106 let occurrence = node_key
107 .split(':')
108 .nth(1)
109 .and_then(|field| field.parse().ok())
110 .unwrap_or(0);
111 Address::node(component, occurrence, node_key)
112 }
113
114 pub fn is_component_level(&self) -> bool {
116 self.node_key.is_none()
117 }
118
119 pub fn overlaps(&self, other: &Address) -> bool {
131 if self.component != other.component {
132 return false;
133 }
134 match (&self.node_key, &other.node_key) {
135 (Some(a), Some(b)) => a == b,
136 _ => true,
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
144pub struct TurnScope {
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub driver: Option<Address>,
148 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 pub read_set: Vec<Address>,
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
153 pub write_set: Vec<Address>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub exchange_tail_floor: Option<usize>,
163}
164
165impl TurnScope {
166 pub fn for_driver(driver: Option<Address>) -> Self {
169 Self::for_driver_with_exchange_tail(driver, None)
170 }
171
172 pub fn for_driver_with_exchange_tail(
182 driver: Option<Address>,
183 exchange_tail_floor: Option<usize>,
184 ) -> Self {
185 let exchange = Address::component(COMPONENT_EXCHANGE, 0);
186 let mut read_set = Vec::new();
187 if let Some(driver) = &driver {
188 read_set.push(driver.clone());
189 }
190 read_set.push(exchange.clone());
191
192 let mut write_set = managed_output_write_set();
193 if let Some(driver) = &driver {
194 write_set.push(driver.clone());
196 }
197
198 TurnScope {
199 driver,
200 read_set: dedupe_addresses(read_set),
201 write_set: dedupe_addresses(write_set),
202 exchange_tail_floor,
203 }
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum AffectednessClass {
212 Independent,
214 InputAffecting,
216 OutputContended,
218 StructuralDependency,
220 ProvenanceSpoofed,
222}
223
224impl AffectednessClass {
225 pub fn affects_turn(self) -> bool {
228 matches!(
229 self,
230 AffectednessClass::InputAffecting
231 | AffectednessClass::OutputContended
232 | AffectednessClass::StructuralDependency
233 )
234 }
235
236 pub fn as_str(self) -> &'static str {
238 match self {
239 AffectednessClass::Independent => "independent",
240 AffectednessClass::InputAffecting => "input_affecting",
241 AffectednessClass::OutputContended => "output_contended",
242 AffectednessClass::StructuralDependency => "structural_dependency",
243 AffectednessClass::ProvenanceSpoofed => "provenance_spoofed",
244 }
245 }
246}
247
248fn scope_membership(
259 op_address: &Address,
260 op_index: Option<usize>,
261 scope: &TurnScope,
262) -> (bool, bool) {
263 if op_address.component == COMPONENT_EXCHANGE
264 && let Some(floor) = scope.exchange_tail_floor
265 {
266 let is_tail = op_index.is_some_and(|index| index >= floor);
267 return (is_tail, is_tail);
268 }
269 (
270 scope.read_set.iter().any(|a| op_address.overlaps(a)),
271 scope.write_set.iter().any(|a| op_address.overlaps(a)),
272 )
273}
274
275pub fn classify_op(
288 actor: crate::op_log::OpActor,
289 op_kind: &str,
290 op_address: &Address,
291 op_index: Option<usize>,
292 scope: &TurnScope,
293) -> AffectednessClass {
294 let (in_read, in_write) = scope_membership(op_address, op_index, scope);
295 if !in_read && !in_write {
296 return AffectednessClass::Independent;
297 }
298 if actor == crate::op_log::OpActor::LiveBuffer {
299 return AffectednessClass::ProvenanceSpoofed;
300 }
301 if matches!(op_kind, "remove" | "move") {
302 return AffectednessClass::StructuralDependency;
303 }
304 if in_read {
305 return AffectednessClass::InputAffecting;
306 }
307 AffectednessClass::OutputContended
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312pub struct ClassifiedOp {
313 pub component: String,
314 pub node_key: String,
315 pub op_kind: String,
316 pub actor: crate::op_log::OpActor,
317 pub class: AffectednessClass,
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
322pub struct CycleAffectedness {
323 pub turn_affected: bool,
325 #[serde(default, skip_serializing_if = "Vec::is_empty")]
327 pub classified: Vec<ClassifiedOp>,
328}
329
330pub fn op_address(op: &crate::op_log::DocumentOp) -> Address {
332 Address::from_component_node_key(&op.component, &op.node_key)
333}
334
335pub fn classify_cycle(ops: &[crate::op_log::DocumentOp], scope: &TurnScope) -> CycleAffectedness {
339 let classified: Vec<ClassifiedOp> = ops
340 .iter()
341 .map(|op| {
342 let address = op_address(op);
343 let class = classify_op(op.actor, &op.op_kind, &address, op.node_index, scope);
344 ClassifiedOp {
345 component: op.component.clone(),
346 node_key: op.node_key.clone(),
347 op_kind: op.op_kind.clone(),
348 actor: op.actor,
349 class,
350 }
351 })
352 .collect();
353 let turn_affected = classified.iter().any(|op| op.class.affects_turn());
354 CycleAffectedness {
355 turn_affected,
356 classified,
357 }
358}
359
360fn dedupe_addresses(addresses: Vec<Address>) -> Vec<Address> {
361 let mut seen = Vec::new();
362 let mut out = Vec::new();
363 for address in addresses {
364 if seen.contains(&address) {
365 continue;
366 }
367 seen.push(address.clone());
368 out.push(address);
369 }
370 out
371}
372
373fn managed_output_write_set() -> Vec<Address> {
374 MANAGED_OUTPUT_COMPONENTS
375 .iter()
376 .map(|component| Address::component(component, 0))
377 .collect()
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 #[test]
385 fn for_driver_with_queue_node_covers_input_and_output() {
386 let driver = Address::node(COMPONENT_QUEUE, 0, "queue:0:op-scoped-drift-2:0");
387 let scope = TurnScope::for_driver(Some(driver.clone()));
388 assert_eq!(scope.driver.as_ref(), Some(&driver));
389 assert_eq!(scope.read_set[0], driver);
391 assert!(
392 scope
393 .read_set
394 .contains(&Address::component(COMPONENT_EXCHANGE, 0))
395 );
396 for &component in MANAGED_OUTPUT_COMPONENTS {
398 assert!(
399 scope.write_set.contains(&Address::component(component, 0)),
400 "write set must cover managed output component {component}"
401 );
402 }
403 assert!(scope.write_set.contains(&driver));
404 }
405
406 #[test]
407 fn managed_component_contract_names_every_conflict_surface() {
408 assert_eq!(
409 MANAGED_CONFLICT_COMPONENTS,
410 &[
411 COMPONENT_EXCHANGE,
412 COMPONENT_QUEUE,
413 COMPONENT_BACKLOG,
414 COMPONENT_STATUS,
415 COMPONENT_REVIEW,
416 COMPONENT_DONE,
417 COMPONENT_DONE_ARCHIVE,
418 COMPONENT_GITLINK,
419 COMPONENT_EDITOR_WRITEBACK,
420 ]
421 );
422 }
423
424 #[test]
425 fn for_driver_write_set_orders_managed_outputs_before_driver() {
426 let driver = Address::node(COMPONENT_QUEUE, 0, "queue:0:driver:0");
427 let scope = TurnScope::for_driver(Some(driver.clone()));
428 let mut expected = managed_output_write_set();
429 expected.push(driver);
430 assert_eq!(scope.write_set, expected);
431 }
432
433 #[test]
434 fn for_driver_without_node_only_lists_output_components() {
435 let scope = TurnScope::for_driver(None);
436 assert!(scope.driver.is_none());
437 assert_eq!(
438 scope.read_set,
439 vec![Address::component(COMPONENT_EXCHANGE, 0)]
440 );
441 assert_eq!(scope.write_set, managed_output_write_set());
442 assert!(
444 scope
445 .write_set
446 .iter()
447 .all(|a| a.component != COMPONENT_QUEUE)
448 );
449 }
450
451 #[test]
452 fn addresses_are_deduped_preserving_order() {
453 let exchange = Address::component(COMPONENT_EXCHANGE, 0);
454 let deduped = dedupe_addresses(vec![
455 exchange.clone(),
456 Address::component(COMPONENT_BACKLOG, 0),
457 exchange.clone(),
458 ]);
459 assert_eq!(
460 deduped,
461 vec![exchange, Address::component(COMPONENT_BACKLOG, 0)]
462 );
463 }
464
465 #[test]
466 fn turn_scope_serde_round_trips() {
467 let scope = TurnScope::for_driver(Some(Address::node("queue", 0, "queue:0:alpha:0")));
468 let json = serde_json::to_string(&scope).unwrap();
469 let parsed: TurnScope = serde_json::from_str(&json).unwrap();
470 assert_eq!(parsed, scope);
471 }
472
473 #[test]
474 fn overlaps_matches_component_level_and_same_node() {
475 let comp = Address::component("queue", 0);
476 let node_a = Address::node("queue", 0, "queue:0:a:0");
477 let node_b = Address::node("queue", 0, "queue:0:b:0");
478 assert!(comp.overlaps(&node_a));
480 assert!(node_a.overlaps(&comp));
481 assert!(!node_a.overlaps(&node_b));
483 assert!(comp.overlaps(&Address::component("queue", 1)));
486 assert!(Address::component("queue", 1).overlaps(&node_a));
487 assert!(!comp.overlaps(&Address::component("backlog", 0)));
489 }
490
491 fn scope_for(node_key: &str) -> TurnScope {
492 TurnScope::for_driver(Some(Address::node("queue", 0, node_key)))
493 }
494
495 #[test]
496 fn classify_independent_when_outside_scope() {
497 use crate::op_log::OpActor;
498 let scope = scope_for("queue:0:driver:0");
499 let addr = Address::node("icebox", 0, "icebox:0:note:0");
501 assert_eq!(
502 classify_op(OpActor::User, "insert", &addr, None, &scope),
503 AffectednessClass::Independent
504 );
505 let sibling = Address::node("queue", 0, "queue:0:other:0");
507 assert_eq!(
508 classify_op(OpActor::User, "insert", &sibling, None, &scope),
509 AffectednessClass::Independent
510 );
511 }
512
513 #[test]
514 fn classify_input_affecting_on_driver_edit() {
515 use crate::op_log::OpActor;
516 let scope = scope_for("queue:0:driver:0");
517 let driver = Address::node("queue", 0, "queue:0:driver:0");
518 assert_eq!(
519 classify_op(OpActor::User, "replace", &driver, None, &scope),
520 AffectednessClass::InputAffecting
521 );
522 }
523
524 #[test]
525 fn classify_structural_on_driver_removal() {
526 use crate::op_log::OpActor;
527 let scope = scope_for("queue:0:driver:0");
528 let driver = Address::node("queue", 0, "queue:0:driver:0");
529 assert_eq!(
530 classify_op(OpActor::User, "remove", &driver, None, &scope),
531 AffectednessClass::StructuralDependency
532 );
533 }
534
535 #[test]
536 fn classify_output_contended_on_write_set_only() {
537 use crate::op_log::OpActor;
538 let scope = scope_for("queue:0:driver:0");
539 let backlog = Address::node(COMPONENT_BACKLOG, 0, "backlog:0:item:0");
541 assert_eq!(
542 classify_op(
543 OpActor::ForeignSupervisor,
544 "replace",
545 &backlog,
546 None,
547 &scope
548 ),
549 AffectednessClass::OutputContended
550 );
551 }
552
553 #[test]
554 fn classify_all_managed_output_surfaces_by_component_scope() {
555 use crate::op_log::OpActor;
556
557 let scope = scope_for("queue:0:driver:0");
558 for &component in MANAGED_OUTPUT_COMPONENTS {
559 let addr = Address::node(component, 0, &format!("{component}:0:item:0"));
560 let expected = if component == COMPONENT_EXCHANGE {
561 AffectednessClass::InputAffecting
562 } else {
563 AffectednessClass::OutputContended
564 };
565 assert_eq!(
566 classify_op(OpActor::User, "replace", &addr, Some(0), &scope),
567 expected,
568 "component {component} should be classified within the managed scope"
569 );
570 }
571
572 let sibling_queue = Address::node(COMPONENT_QUEUE, 0, "queue:0:sibling:0");
573 assert_eq!(
574 classify_op(OpActor::User, "insert", &sibling_queue, Some(1), &scope),
575 AffectednessClass::Independent,
576 "queue remains node-scoped so unrelated queue work can merge"
577 );
578 }
579
580 #[test]
581 fn classify_provenance_spoofed_for_live_buffer_in_scope() {
582 use crate::op_log::OpActor;
583 let scope = scope_for("queue:0:driver:0");
584 let exchange = Address::component(COMPONENT_EXCHANGE, 0);
586 assert_eq!(
587 classify_op(OpActor::LiveBuffer, "replace", &exchange, None, &scope),
588 AffectednessClass::ProvenanceSpoofed
589 );
590 let icebox = Address::node("icebox", 0, "icebox:0:n:0");
592 assert_eq!(
593 classify_op(OpActor::LiveBuffer, "replace", &icebox, None, &scope),
594 AffectednessClass::Independent
595 );
596 }
597
598 fn exchange_tail_scope(floor: usize) -> TurnScope {
599 TurnScope::for_driver_with_exchange_tail(
600 Some(Address::node("queue", 0, "queue:0:driver:0")),
601 Some(floor),
602 )
603 }
604
605 #[test]
606 fn exchange_tail_floor_makes_old_block_edit_independent() {
607 use crate::op_log::OpActor;
611 let scope = exchange_tail_scope(2);
612 let old_block = Address::node("exchange", 0, "exchange:0:ft-old:0");
613 assert_eq!(
614 classify_op(OpActor::User, "replace", &old_block, Some(0), &scope),
615 AffectednessClass::Independent
616 );
617 assert_eq!(
619 classify_op(OpActor::User, "remove", &old_block, Some(0), &scope),
620 AffectednessClass::Independent
621 );
622 }
623
624 #[test]
625 fn exchange_tail_floor_keeps_tail_append_affecting() {
626 use crate::op_log::OpActor;
629 let scope = exchange_tail_scope(2);
630 let tail_append = Address::node("exchange", 0, "exchange:0:ft-new:0");
631 assert_eq!(
632 classify_op(OpActor::User, "insert", &tail_append, Some(2), &scope),
633 AffectednessClass::InputAffecting
634 );
635 assert_eq!(
637 classify_op(OpActor::User, "insert", &tail_append, Some(5), &scope),
638 AffectednessClass::InputAffecting
639 );
640 }
641
642 #[test]
643 fn exchange_tail_floor_suppresses_live_buffer_tail_drift() {
644 use crate::op_log::OpActor;
647 let scope = exchange_tail_scope(2);
648 let tail = Address::node("exchange", 0, "exchange:0:ft-x:0");
649 assert_eq!(
650 classify_op(OpActor::LiveBuffer, "insert", &tail, Some(2), &scope),
651 AffectednessClass::ProvenanceSpoofed
652 );
653 }
654
655 #[test]
656 fn exchange_without_floor_keeps_whole_component_behavior() {
657 use crate::op_log::OpActor;
661 let scope = scope_for("queue:0:driver:0");
662 assert!(scope.exchange_tail_floor.is_none());
663 let old_block = Address::node("exchange", 0, "exchange:0:ft-old:0");
664 assert_eq!(
665 classify_op(OpActor::User, "replace", &old_block, Some(0), &scope),
666 AffectednessClass::InputAffecting
667 );
668 }
669
670 #[test]
671 fn classify_cycle_narrows_exchange_old_edit_but_keeps_tail() {
672 use crate::op_log::{CausalClock, DocumentOp, OpActor};
675 let scope = exchange_tail_scope(2);
676 let mk = |node_key: &str, op_kind: &str, idx: Option<usize>| DocumentOp {
677 document_path: "plan.md".to_string(),
678 component: "exchange".to_string(),
679 node_key: node_key.to_string(),
680 node_index: idx,
681 item_id: String::new(),
682 op_kind: op_kind.to_string(),
683 actor: OpActor::User,
684 clock: CausalClock::default(),
685 before_preview: None,
686 after_preview: None,
687 recorded_at: None,
688 };
689 let old_only = vec![mk("exchange:0:ft-old:0", "replace", Some(0))];
690 assert!(
691 !classify_cycle(&old_only, &scope).turn_affected,
692 "an old-block exchange edit alone must not affect the turn"
693 );
694 let with_tail = vec![
695 mk("exchange:0:ft-old:0", "replace", Some(0)),
696 mk("exchange:0:ft-new:0", "insert", Some(2)),
697 ];
698 assert!(
699 classify_cycle(&with_tail, &scope).turn_affected,
700 "a tail append must still affect the turn"
701 );
702 }
703
704 #[test]
705 fn classify_cycle_aggregates_turn_affected() {
706 use crate::op_log::{CausalClock, DocumentOp, OpActor};
707 let scope = scope_for("queue:0:driver:0");
708 let mk = |component: &str, node_key: &str, op_kind: &str, actor: OpActor| DocumentOp {
709 document_path: "plan.md".to_string(),
710 component: component.to_string(),
711 node_key: node_key.to_string(),
712 node_index: None,
713 item_id: String::new(),
714 op_kind: op_kind.to_string(),
715 actor,
716 clock: CausalClock::default(),
717 before_preview: None,
718 after_preview: None,
719 recorded_at: None,
720 };
721 let independent = vec![mk("queue", "queue:0:other:0", "insert", OpActor::User)];
723 assert!(!classify_cycle(&independent, &scope).turn_affected);
724
725 let mixed = vec![
727 mk("queue", "queue:0:other:0", "insert", OpActor::User),
728 mk("queue", "queue:0:driver:0", "replace", OpActor::User),
729 ];
730 let summary = classify_cycle(&mixed, &scope);
731 assert!(summary.turn_affected);
732 assert_eq!(summary.classified.len(), 2);
733 assert_eq!(summary.classified[0].class, AffectednessClass::Independent);
734 assert_eq!(
735 summary.classified[1].class,
736 AffectednessClass::InputAffecting
737 );
738 }
739
740 #[test]
741 fn affects_turn_matches_taxonomy() {
742 assert!(!AffectednessClass::Independent.affects_turn());
743 assert!(!AffectednessClass::ProvenanceSpoofed.affects_turn());
744 assert!(AffectednessClass::InputAffecting.affects_turn());
745 assert!(AffectednessClass::OutputContended.affects_turn());
746 assert!(AffectednessClass::StructuralDependency.affects_turn());
747 }
748}