use serde::{Deserialize, Serialize};
pub const COMPONENT_EXCHANGE: &str = "exchange";
pub const COMPONENT_QUEUE: &str = "queue";
pub const COMPONENT_BACKLOG: &str = "backlog";
pub const COMPONENT_STATUS: &str = "status";
pub const COMPONENT_REVIEW: &str = "review";
pub const COMPONENT_DONE: &str = "done";
pub const COMPONENT_DONE_ARCHIVE: &str = "done_archive";
pub const COMPONENT_GITLINK: &str = "gitlink";
pub const COMPONENT_EDITOR_WRITEBACK: &str = "editor_writeback";
pub const MANAGED_OUTPUT_COMPONENTS: &[&str] = &[
COMPONENT_EXCHANGE,
COMPONENT_BACKLOG,
COMPONENT_STATUS,
COMPONENT_REVIEW,
COMPONENT_DONE,
COMPONENT_DONE_ARCHIVE,
COMPONENT_GITLINK,
COMPONENT_EDITOR_WRITEBACK,
];
pub const MANAGED_CONFLICT_COMPONENTS: &[&str] = &[
COMPONENT_EXCHANGE,
COMPONENT_QUEUE,
COMPONENT_BACKLOG,
COMPONENT_STATUS,
COMPONENT_REVIEW,
COMPONENT_DONE,
COMPONENT_DONE_ARCHIVE,
COMPONENT_GITLINK,
COMPONENT_EDITOR_WRITEBACK,
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Address {
pub component: String,
pub occurrence: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_key: Option<String>,
}
impl Address {
pub fn component(name: &str, occurrence: usize) -> Self {
Address {
component: name.to_string(),
occurrence,
node_key: None,
}
}
pub fn node(component: &str, occurrence: usize, node_key: &str) -> Self {
Address {
component: component.to_string(),
occurrence,
node_key: Some(node_key.to_string()),
}
}
pub fn from_component_node_key(component: &str, node_key: &str) -> Self {
let occurrence = node_key
.split(':')
.nth(1)
.and_then(|field| field.parse().ok())
.unwrap_or(0);
Address::node(component, occurrence, node_key)
}
pub fn is_component_level(&self) -> bool {
self.node_key.is_none()
}
pub fn overlaps(&self, other: &Address) -> bool {
if self.component != other.component {
return false;
}
match (&self.node_key, &other.node_key) {
(Some(a), Some(b)) => a == b,
_ => true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TurnScope {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub driver: Option<Address>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read_set: Vec<Address>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub write_set: Vec<Address>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exchange_tail_floor: Option<usize>,
}
impl TurnScope {
pub fn for_driver(driver: Option<Address>) -> Self {
Self::for_driver_with_exchange_tail(driver, None)
}
pub fn for_driver_with_exchange_tail(
driver: Option<Address>,
exchange_tail_floor: Option<usize>,
) -> Self {
let exchange = Address::component(COMPONENT_EXCHANGE, 0);
let mut read_set = Vec::new();
if let Some(driver) = &driver {
read_set.push(driver.clone());
}
read_set.push(exchange.clone());
let mut write_set = managed_output_write_set();
if let Some(driver) = &driver {
write_set.push(driver.clone());
}
TurnScope {
driver,
read_set: dedupe_addresses(read_set),
write_set: dedupe_addresses(write_set),
exchange_tail_floor,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AffectednessClass {
Independent,
InputAffecting,
OutputContended,
StructuralDependency,
ProvenanceSpoofed,
}
impl AffectednessClass {
pub fn affects_turn(self) -> bool {
matches!(
self,
AffectednessClass::InputAffecting
| AffectednessClass::OutputContended
| AffectednessClass::StructuralDependency
)
}
pub fn as_str(self) -> &'static str {
match self {
AffectednessClass::Independent => "independent",
AffectednessClass::InputAffecting => "input_affecting",
AffectednessClass::OutputContended => "output_contended",
AffectednessClass::StructuralDependency => "structural_dependency",
AffectednessClass::ProvenanceSpoofed => "provenance_spoofed",
}
}
}
fn scope_membership(
op_address: &Address,
op_index: Option<usize>,
scope: &TurnScope,
) -> (bool, bool) {
if op_address.component == COMPONENT_EXCHANGE
&& let Some(floor) = scope.exchange_tail_floor
{
let is_tail = op_index.is_some_and(|index| index >= floor);
return (is_tail, is_tail);
}
(
scope.read_set.iter().any(|a| op_address.overlaps(a)),
scope.write_set.iter().any(|a| op_address.overlaps(a)),
)
}
pub fn classify_op(
actor: crate::op_log::OpActor,
op_kind: &str,
op_address: &Address,
op_index: Option<usize>,
scope: &TurnScope,
) -> AffectednessClass {
let (in_read, in_write) = scope_membership(op_address, op_index, scope);
if !in_read && !in_write {
return AffectednessClass::Independent;
}
if actor == crate::op_log::OpActor::LiveBuffer {
return AffectednessClass::ProvenanceSpoofed;
}
if matches!(op_kind, "remove" | "move") {
return AffectednessClass::StructuralDependency;
}
if in_read {
return AffectednessClass::InputAffecting;
}
AffectednessClass::OutputContended
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClassifiedOp {
pub component: String,
pub node_key: String,
pub op_kind: String,
pub actor: crate::op_log::OpActor,
pub class: AffectednessClass,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct CycleAffectedness {
pub turn_affected: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub classified: Vec<ClassifiedOp>,
}
pub fn op_address(op: &crate::op_log::DocumentOp) -> Address {
Address::from_component_node_key(&op.component, &op.node_key)
}
pub fn classify_cycle(ops: &[crate::op_log::DocumentOp], scope: &TurnScope) -> CycleAffectedness {
let classified: Vec<ClassifiedOp> = ops
.iter()
.map(|op| {
let address = op_address(op);
let class = classify_op(op.actor, &op.op_kind, &address, op.node_index, scope);
ClassifiedOp {
component: op.component.clone(),
node_key: op.node_key.clone(),
op_kind: op.op_kind.clone(),
actor: op.actor,
class,
}
})
.collect();
let turn_affected = classified.iter().any(|op| op.class.affects_turn());
CycleAffectedness {
turn_affected,
classified,
}
}
fn dedupe_addresses(addresses: Vec<Address>) -> Vec<Address> {
let mut seen = Vec::new();
let mut out = Vec::new();
for address in addresses {
if seen.contains(&address) {
continue;
}
seen.push(address.clone());
out.push(address);
}
out
}
fn managed_output_write_set() -> Vec<Address> {
MANAGED_OUTPUT_COMPONENTS
.iter()
.map(|component| Address::component(component, 0))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn for_driver_with_queue_node_covers_input_and_output() {
let driver = Address::node(COMPONENT_QUEUE, 0, "queue:0:op-scoped-drift-2:0");
let scope = TurnScope::for_driver(Some(driver.clone()));
assert_eq!(scope.driver.as_ref(), Some(&driver));
assert_eq!(scope.read_set[0], driver);
assert!(
scope
.read_set
.contains(&Address::component(COMPONENT_EXCHANGE, 0))
);
for &component in MANAGED_OUTPUT_COMPONENTS {
assert!(
scope.write_set.contains(&Address::component(component, 0)),
"write set must cover managed output component {component}"
);
}
assert!(scope.write_set.contains(&driver));
}
#[test]
fn managed_component_contract_names_every_conflict_surface() {
assert_eq!(
MANAGED_CONFLICT_COMPONENTS,
&[
COMPONENT_EXCHANGE,
COMPONENT_QUEUE,
COMPONENT_BACKLOG,
COMPONENT_STATUS,
COMPONENT_REVIEW,
COMPONENT_DONE,
COMPONENT_DONE_ARCHIVE,
COMPONENT_GITLINK,
COMPONENT_EDITOR_WRITEBACK,
]
);
}
#[test]
fn for_driver_write_set_orders_managed_outputs_before_driver() {
let driver = Address::node(COMPONENT_QUEUE, 0, "queue:0:driver:0");
let scope = TurnScope::for_driver(Some(driver.clone()));
let mut expected = managed_output_write_set();
expected.push(driver);
assert_eq!(scope.write_set, expected);
}
#[test]
fn for_driver_without_node_only_lists_output_components() {
let scope = TurnScope::for_driver(None);
assert!(scope.driver.is_none());
assert_eq!(
scope.read_set,
vec![Address::component(COMPONENT_EXCHANGE, 0)]
);
assert_eq!(scope.write_set, managed_output_write_set());
assert!(
scope
.write_set
.iter()
.all(|a| a.component != COMPONENT_QUEUE)
);
}
#[test]
fn addresses_are_deduped_preserving_order() {
let exchange = Address::component(COMPONENT_EXCHANGE, 0);
let deduped = dedupe_addresses(vec![
exchange.clone(),
Address::component(COMPONENT_BACKLOG, 0),
exchange.clone(),
]);
assert_eq!(
deduped,
vec![exchange, Address::component(COMPONENT_BACKLOG, 0)]
);
}
#[test]
fn turn_scope_serde_round_trips() {
let scope = TurnScope::for_driver(Some(Address::node("queue", 0, "queue:0:alpha:0")));
let json = serde_json::to_string(&scope).unwrap();
let parsed: TurnScope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, scope);
}
#[test]
fn overlaps_matches_component_level_and_same_node() {
let comp = Address::component("queue", 0);
let node_a = Address::node("queue", 0, "queue:0:a:0");
let node_b = Address::node("queue", 0, "queue:0:b:0");
assert!(comp.overlaps(&node_a));
assert!(node_a.overlaps(&comp));
assert!(!node_a.overlaps(&node_b));
assert!(comp.overlaps(&Address::component("queue", 1)));
assert!(Address::component("queue", 1).overlaps(&node_a));
assert!(!comp.overlaps(&Address::component("backlog", 0)));
}
fn scope_for(node_key: &str) -> TurnScope {
TurnScope::for_driver(Some(Address::node("queue", 0, node_key)))
}
#[test]
fn classify_independent_when_outside_scope() {
use crate::op_log::OpActor;
let scope = scope_for("queue:0:driver:0");
let addr = Address::node("icebox", 0, "icebox:0:note:0");
assert_eq!(
classify_op(OpActor::User, "insert", &addr, None, &scope),
AffectednessClass::Independent
);
let sibling = Address::node("queue", 0, "queue:0:other:0");
assert_eq!(
classify_op(OpActor::User, "insert", &sibling, None, &scope),
AffectednessClass::Independent
);
}
#[test]
fn classify_input_affecting_on_driver_edit() {
use crate::op_log::OpActor;
let scope = scope_for("queue:0:driver:0");
let driver = Address::node("queue", 0, "queue:0:driver:0");
assert_eq!(
classify_op(OpActor::User, "replace", &driver, None, &scope),
AffectednessClass::InputAffecting
);
}
#[test]
fn classify_structural_on_driver_removal() {
use crate::op_log::OpActor;
let scope = scope_for("queue:0:driver:0");
let driver = Address::node("queue", 0, "queue:0:driver:0");
assert_eq!(
classify_op(OpActor::User, "remove", &driver, None, &scope),
AffectednessClass::StructuralDependency
);
}
#[test]
fn classify_output_contended_on_write_set_only() {
use crate::op_log::OpActor;
let scope = scope_for("queue:0:driver:0");
let backlog = Address::node(COMPONENT_BACKLOG, 0, "backlog:0:item:0");
assert_eq!(
classify_op(
OpActor::ForeignSupervisor,
"replace",
&backlog,
None,
&scope
),
AffectednessClass::OutputContended
);
}
#[test]
fn classify_all_managed_output_surfaces_by_component_scope() {
use crate::op_log::OpActor;
let scope = scope_for("queue:0:driver:0");
for &component in MANAGED_OUTPUT_COMPONENTS {
let addr = Address::node(component, 0, &format!("{component}:0:item:0"));
let expected = if component == COMPONENT_EXCHANGE {
AffectednessClass::InputAffecting
} else {
AffectednessClass::OutputContended
};
assert_eq!(
classify_op(OpActor::User, "replace", &addr, Some(0), &scope),
expected,
"component {component} should be classified within the managed scope"
);
}
let sibling_queue = Address::node(COMPONENT_QUEUE, 0, "queue:0:sibling:0");
assert_eq!(
classify_op(OpActor::User, "insert", &sibling_queue, Some(1), &scope),
AffectednessClass::Independent,
"queue remains node-scoped so unrelated queue work can merge"
);
}
#[test]
fn classify_provenance_spoofed_for_live_buffer_in_scope() {
use crate::op_log::OpActor;
let scope = scope_for("queue:0:driver:0");
let exchange = Address::component(COMPONENT_EXCHANGE, 0);
assert_eq!(
classify_op(OpActor::LiveBuffer, "replace", &exchange, None, &scope),
AffectednessClass::ProvenanceSpoofed
);
let icebox = Address::node("icebox", 0, "icebox:0:n:0");
assert_eq!(
classify_op(OpActor::LiveBuffer, "replace", &icebox, None, &scope),
AffectednessClass::Independent
);
}
fn exchange_tail_scope(floor: usize) -> TurnScope {
TurnScope::for_driver_with_exchange_tail(
Some(Address::node("queue", 0, "queue:0:driver:0")),
Some(floor),
)
}
#[test]
fn exchange_tail_floor_makes_old_block_edit_independent() {
use crate::op_log::OpActor;
let scope = exchange_tail_scope(2);
let old_block = Address::node("exchange", 0, "exchange:0:ft-old:0");
assert_eq!(
classify_op(OpActor::User, "replace", &old_block, Some(0), &scope),
AffectednessClass::Independent
);
assert_eq!(
classify_op(OpActor::User, "remove", &old_block, Some(0), &scope),
AffectednessClass::Independent
);
}
#[test]
fn exchange_tail_floor_keeps_tail_append_affecting() {
use crate::op_log::OpActor;
let scope = exchange_tail_scope(2);
let tail_append = Address::node("exchange", 0, "exchange:0:ft-new:0");
assert_eq!(
classify_op(OpActor::User, "insert", &tail_append, Some(2), &scope),
AffectednessClass::InputAffecting
);
assert_eq!(
classify_op(OpActor::User, "insert", &tail_append, Some(5), &scope),
AffectednessClass::InputAffecting
);
}
#[test]
fn exchange_tail_floor_suppresses_live_buffer_tail_drift() {
use crate::op_log::OpActor;
let scope = exchange_tail_scope(2);
let tail = Address::node("exchange", 0, "exchange:0:ft-x:0");
assert_eq!(
classify_op(OpActor::LiveBuffer, "insert", &tail, Some(2), &scope),
AffectednessClass::ProvenanceSpoofed
);
}
#[test]
fn exchange_without_floor_keeps_whole_component_behavior() {
use crate::op_log::OpActor;
let scope = scope_for("queue:0:driver:0");
assert!(scope.exchange_tail_floor.is_none());
let old_block = Address::node("exchange", 0, "exchange:0:ft-old:0");
assert_eq!(
classify_op(OpActor::User, "replace", &old_block, Some(0), &scope),
AffectednessClass::InputAffecting
);
}
#[test]
fn classify_cycle_narrows_exchange_old_edit_but_keeps_tail() {
use crate::op_log::{CausalClock, DocumentOp, OpActor};
let scope = exchange_tail_scope(2);
let mk = |node_key: &str, op_kind: &str, idx: Option<usize>| DocumentOp {
document_path: "plan.md".to_string(),
component: "exchange".to_string(),
node_key: node_key.to_string(),
node_index: idx,
item_id: String::new(),
op_kind: op_kind.to_string(),
actor: OpActor::User,
clock: CausalClock::default(),
before_preview: None,
after_preview: None,
recorded_at: None,
};
let old_only = vec![mk("exchange:0:ft-old:0", "replace", Some(0))];
assert!(
!classify_cycle(&old_only, &scope).turn_affected,
"an old-block exchange edit alone must not affect the turn"
);
let with_tail = vec![
mk("exchange:0:ft-old:0", "replace", Some(0)),
mk("exchange:0:ft-new:0", "insert", Some(2)),
];
assert!(
classify_cycle(&with_tail, &scope).turn_affected,
"a tail append must still affect the turn"
);
}
#[test]
fn classify_cycle_aggregates_turn_affected() {
use crate::op_log::{CausalClock, DocumentOp, OpActor};
let scope = scope_for("queue:0:driver:0");
let mk = |component: &str, node_key: &str, op_kind: &str, actor: OpActor| DocumentOp {
document_path: "plan.md".to_string(),
component: component.to_string(),
node_key: node_key.to_string(),
node_index: None,
item_id: String::new(),
op_kind: op_kind.to_string(),
actor,
clock: CausalClock::default(),
before_preview: None,
after_preview: None,
recorded_at: None,
};
let independent = vec![mk("queue", "queue:0:other:0", "insert", OpActor::User)];
assert!(!classify_cycle(&independent, &scope).turn_affected);
let mixed = vec![
mk("queue", "queue:0:other:0", "insert", OpActor::User),
mk("queue", "queue:0:driver:0", "replace", OpActor::User),
];
let summary = classify_cycle(&mixed, &scope);
assert!(summary.turn_affected);
assert_eq!(summary.classified.len(), 2);
assert_eq!(summary.classified[0].class, AffectednessClass::Independent);
assert_eq!(
summary.classified[1].class,
AffectednessClass::InputAffecting
);
}
#[test]
fn affects_turn_matches_taxonomy() {
assert!(!AffectednessClass::Independent.affects_turn());
assert!(!AffectednessClass::ProvenanceSpoofed.affects_turn());
assert!(AffectednessClass::InputAffecting.affects_turn());
assert!(AffectednessClass::OutputContended.affects_turn());
assert!(AffectednessClass::StructuralDependency.affects_turn());
}
}