Skip to main content

bones_core/graph/
blocking.rs

1//! Blocking and relates dependency graph built from CRDT item states.
2//!
3//! # Overview
4//!
5//! This module materializes a scheduling dependency graph from [`WorkItemState`]
6//! OR-Sets. Two orthogonal link types are supported:
7//!
8//! - **Blocking** (`blocked_by`): scheduling dependency. An item with non-empty
9//!   `blocked_by` is considered blocked and will not appear in "ready" work.
10//! - **Relates** (`related_to`): informational link. Has no effect on scheduling.
11//!
12//! # Data Model
13//!
14//! Both link types are stored in [`WorkItemState`] as OR-Sets of item IDs:
15//!
16//! - `blocked_by: OrSet<String>` — items that must complete before this one
17//! - `related_to: OrSet<String>` — items informationally related to this one
18//!
19//! The OR-Set semantics ensure convergent behavior under concurrent
20//! `item.link` / `item.unlink` events: concurrent add+remove resolves
21//! as add-wins (the link survives).
22//!
23//! # Cross-Goal Dependencies
24//!
25//! An item's `blocked_by` set may reference items from any goal, not just
26//! the parent goal. `BlockingGraph` treats all item IDs uniformly.
27//!
28//! # Causation
29//!
30//! The `causation` field on `item.link` events captures audit provenance
31//! (which event caused this link). It is stored in the event stream and
32//! queryable via the event DAG — it is NOT a graph edge in the blocking
33//! graph.
34//!
35//! # Usage
36//!
37//! ```rust,ignore
38//! use std::collections::HashMap;
39//! use bones_core::graph::blocking::BlockingGraph;
40//! use bones_core::crdt::item_state::WorkItemState;
41//!
42//! let states: HashMap<String, WorkItemState> = /* ... */;
43//! let graph = BlockingGraph::from_states(&states);
44//!
45//! if graph.is_blocked("bn-task1") {
46//!     let blockers = graph.get_blockers("bn-task1");
47//!     println!("bn-task1 is blocked by: {blockers:?}");
48//! }
49//!
50//! let ready = graph.ready_items();
51//! println!("Ready items: {ready:?}");
52//! ```
53
54#![allow(clippy::must_use_candidate, clippy::module_name_repetitions)]
55
56use std::collections::{HashMap, HashSet};
57
58use crate::crdt::item_state::WorkItemState;
59
60// ---------------------------------------------------------------------------
61// BlockingGraph
62// ---------------------------------------------------------------------------
63
64/// A blocking/relates dependency graph materialized from CRDT item states.
65///
66/// Constructed from a snapshot of [`WorkItemState`] values keyed by item ID.
67/// The graph is immutable once built — call [`BlockingGraph::from_states`]
68/// again if states change.
69///
70/// # Scheduling Semantics
71///
72/// An item is **blocked** if its `blocked_by` OR-Set is non-empty (at least
73/// one blocking link is present). Blocked items are excluded from "ready" work.
74///
75/// An item is **ready** if it is not blocked (its `blocked_by` OR-Set is empty).
76///
77/// # Relates Semantics
78///
79/// Relates links are informational only. They do not affect the blocked/ready
80/// computation.
81#[derive(Debug, Clone, Default)]
82pub struct BlockingGraph {
83    /// `item_id` → set of `item_ids` that block it.
84    blocked_by: HashMap<String, HashSet<String>>,
85    /// `item_id` → set of related `item_ids`.
86    related_to: HashMap<String, HashSet<String>>,
87    /// All known item IDs (the full key set from the source states map).
88    all_items: HashSet<String>,
89}
90
91impl BlockingGraph {
92    /// Build a [`BlockingGraph`] from a map of item states.
93    ///
94    /// Iterates every state and extracts `blocked_by` and `related_to`
95    /// OR-Set values. Deleted items are included — callers may filter them
96    /// with [`WorkItemState::is_deleted`] before passing states to this function.
97    ///
98    /// # Complexity
99    ///
100    /// O(N * L) where N is the number of items and L is the average number
101    /// of links per item.
102    pub fn from_states(states: &HashMap<String, WorkItemState>) -> Self {
103        let all_items: HashSet<String> = states.keys().cloned().collect();
104        let mut blocked_by: HashMap<String, HashSet<String>> = HashMap::new();
105        let mut related_to: HashMap<String, HashSet<String>> = HashMap::new();
106
107        for (item_id, state) in states {
108            let blockers: HashSet<String> = state.blocked_by_ids().into_iter().cloned().collect();
109            if !blockers.is_empty() {
110                blocked_by.insert(item_id.clone(), blockers);
111            }
112
113            let related: HashSet<String> = state.related_to_ids().into_iter().cloned().collect();
114            if !related.is_empty() {
115                related_to.insert(item_id.clone(), related);
116            }
117        }
118
119        Self {
120            blocked_by,
121            related_to,
122            all_items,
123        }
124    }
125
126    /// Return `true` if the item has at least one blocking dependency.
127    ///
128    /// An item is blocked if its `blocked_by` OR-Set is non-empty. To unblock,
129    /// submit an `item.unlink` event which removes the link from the OR-Set.
130    pub fn is_blocked(&self, item_id: &str) -> bool {
131        self.blocked_by
132            .get(item_id)
133            .is_some_and(|blockers| !blockers.is_empty())
134    }
135
136    /// Return the set of item IDs that block the given item.
137    ///
138    /// Returns an empty set if the item is not blocked or not known.
139    pub fn get_blockers(&self, item_id: &str) -> HashSet<&str> {
140        self.blocked_by
141            .get(item_id)
142            .map(|blockers| blockers.iter().map(String::as_str).collect())
143            .unwrap_or_default()
144    }
145
146    /// Return the set of item IDs related to the given item.
147    ///
148    /// Relates links are informational — they do not affect scheduling.
149    /// Returns an empty set if the item has no relates links or is not known.
150    pub fn get_related(&self, item_id: &str) -> HashSet<&str> {
151        self.related_to
152            .get(item_id)
153            .map(|related| related.iter().map(String::as_str).collect())
154            .unwrap_or_default()
155    }
156
157    /// Return all item IDs that have no active blocking dependencies.
158    ///
159    /// An item is "ready" if its `blocked_by` OR-Set is empty. Items with
160    /// no known state entries are not included — only items present in the
161    /// source states map appear here.
162    ///
163    /// For scheduling purposes, callers typically filter deleted and
164    /// done/archived items before calling [`BlockingGraph::from_states`].
165    pub fn ready_items(&self) -> HashSet<&str> {
166        self.all_items
167            .iter()
168            .filter(|id| !self.is_blocked(id.as_str()))
169            .map(String::as_str)
170            .collect()
171    }
172
173    /// Return all item IDs that have at least one active blocking dependency.
174    pub fn blocked_items(&self) -> HashSet<&str> {
175        self.blocked_by
176            .iter()
177            .filter(|(_, blockers)| !blockers.is_empty())
178            .map(|(id, _)| id.as_str())
179            .collect()
180    }
181
182    /// Return the total number of items in the graph.
183    pub fn len(&self) -> usize {
184        self.all_items.len()
185    }
186
187    /// Return `true` if the graph has no items.
188    pub fn is_empty(&self) -> bool {
189        self.all_items.is_empty()
190    }
191
192    /// Return an iterator over all item IDs in the graph.
193    ///
194    /// Used by cycle detection to enumerate all nodes for full-graph DFS.
195    pub fn all_item_ids(&self) -> impl Iterator<Item = &str> {
196        self.all_items.iter().map(String::as_str)
197    }
198}
199
200// ---------------------------------------------------------------------------
201// Standalone convenience functions
202// ---------------------------------------------------------------------------
203
204/// Check if an item is blocked given a map of states.
205///
206/// Returns `false` if the item is not present in `states`.
207pub fn is_blocked<S: std::hash::BuildHasher>(
208    item_id: &str,
209    states: &HashMap<String, WorkItemState, S>,
210) -> bool {
211    states
212        .get(item_id)
213        .is_some_and(|state| !state.blocked_by.is_empty())
214}
215
216/// Return the set of item IDs blocking the given item.
217///
218/// Returns an empty set if the item is not present or has no blockers.
219pub fn get_blockers<S: std::hash::BuildHasher>(
220    item_id: &str,
221    states: &HashMap<String, WorkItemState, S>,
222) -> HashSet<String> {
223    states
224        .get(item_id)
225        .map(|state| state.blocked_by_ids().into_iter().cloned().collect())
226        .unwrap_or_default()
227}
228
229/// Return item IDs that have no active blocking dependencies.
230///
231/// Iterates all items in `states` and returns those whose `blocked_by`
232/// OR-Set is empty. Order is unspecified.
233pub fn ready_items<S: std::hash::BuildHasher>(
234    states: &HashMap<String, WorkItemState, S>,
235) -> Vec<String> {
236    states
237        .keys()
238        .filter(|id| !is_blocked(id.as_str(), states))
239        .cloned()
240        .collect()
241}
242
243// ---------------------------------------------------------------------------
244// Tests
245// ---------------------------------------------------------------------------
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crate::clock::itc::Stamp;
251    use crate::crdt::item_state::WorkItemState;
252    use crate::event::Event;
253    use crate::event::data::{EventData, LinkData, UnlinkData};
254    use crate::event::types::EventType;
255    use crate::model::item_id::ItemId;
256    use std::collections::BTreeMap;
257
258    // -----------------------------------------------------------------------
259    // Helpers
260    // -----------------------------------------------------------------------
261
262    fn make_link_event(
263        target: &str,
264        link_type: &str,
265        wall_ts: i64,
266        agent: &str,
267        hash: &str,
268    ) -> Event {
269        let mut stamp = Stamp::seed();
270        stamp.event();
271        Event {
272            wall_ts_us: wall_ts,
273            agent: agent.to_string(),
274            itc: stamp.to_string(),
275            parents: vec![],
276            event_type: EventType::Link,
277            item_id: ItemId::new_unchecked("bn-test"),
278            data: EventData::Link(LinkData {
279                target: target.to_string(),
280                link_type: link_type.to_string(),
281                extra: BTreeMap::new(),
282            }),
283            event_hash: hash.to_string(),
284        }
285    }
286
287    fn make_unlink_event(
288        target: &str,
289        link_type: Option<&str>,
290        wall_ts: i64,
291        agent: &str,
292        hash: &str,
293    ) -> Event {
294        let mut stamp = Stamp::seed();
295        stamp.event();
296        Event {
297            wall_ts_us: wall_ts,
298            agent: agent.to_string(),
299            itc: stamp.to_string(),
300            parents: vec![],
301            event_type: EventType::Unlink,
302            item_id: ItemId::new_unchecked("bn-test"),
303            data: EventData::Unlink(UnlinkData {
304                target: target.to_string(),
305                link_type: link_type.map(|s| s.to_string()),
306                extra: BTreeMap::new(),
307            }),
308            event_hash: hash.to_string(),
309        }
310    }
311
312    /// Build a WorkItemState with the given blocking links applied.
313    fn state_with_blockers(blocker_ids: &[&str]) -> WorkItemState {
314        let mut state = WorkItemState::new();
315        for (i, blocker) in blocker_ids.iter().enumerate() {
316            let hash = format!("blake3:link{i}");
317            let event = make_link_event(blocker, "blocks", 1000 + i as i64, "agent", &hash);
318            state.apply_event(&event);
319        }
320        state
321    }
322
323    /// Build a WorkItemState with the given relates links applied.
324    fn state_with_related(related_ids: &[&str]) -> WorkItemState {
325        let mut state = WorkItemState::new();
326        for (i, related) in related_ids.iter().enumerate() {
327            let hash = format!("blake3:rel{i}");
328            let event = make_link_event(related, "related_to", 1000 + i as i64, "agent", &hash);
329            state.apply_event(&event);
330        }
331        state
332    }
333
334    // -----------------------------------------------------------------------
335    // BlockingGraph construction
336    // -----------------------------------------------------------------------
337
338    #[test]
339    fn empty_graph_from_empty_states() {
340        let states: HashMap<String, WorkItemState> = HashMap::new();
341        let graph = BlockingGraph::from_states(&states);
342
343        assert!(graph.is_empty());
344        assert_eq!(graph.len(), 0);
345        assert!(graph.ready_items().is_empty());
346        assert!(graph.blocked_items().is_empty());
347    }
348
349    #[test]
350    fn unblocked_item_is_ready() {
351        let mut states = HashMap::new();
352        states.insert("bn-1".to_string(), WorkItemState::new());
353
354        let graph = BlockingGraph::from_states(&states);
355
356        assert!(!graph.is_blocked("bn-1"));
357        assert!(graph.ready_items().contains("bn-1"));
358        assert!(!graph.blocked_items().contains("bn-1"));
359    }
360
361    #[test]
362    fn blocked_item_is_not_ready() {
363        let mut states = HashMap::new();
364        states.insert("bn-1".to_string(), state_with_blockers(&["bn-2"]));
365        states.insert("bn-2".to_string(), WorkItemState::new());
366
367        let graph = BlockingGraph::from_states(&states);
368
369        assert!(graph.is_blocked("bn-1"));
370        assert!(!graph.ready_items().contains("bn-1"));
371        assert!(graph.blocked_items().contains("bn-1"));
372
373        // The blocker (bn-2) itself is ready.
374        assert!(!graph.is_blocked("bn-2"));
375        assert!(graph.ready_items().contains("bn-2"));
376    }
377
378    #[test]
379    fn multiple_blockers() {
380        let mut states = HashMap::new();
381        states.insert("bn-1".to_string(), state_with_blockers(&["bn-2", "bn-3"]));
382        states.insert("bn-2".to_string(), WorkItemState::new());
383        states.insert("bn-3".to_string(), WorkItemState::new());
384
385        let graph = BlockingGraph::from_states(&states);
386
387        assert!(graph.is_blocked("bn-1"));
388        let blockers = graph.get_blockers("bn-1");
389        assert_eq!(blockers.len(), 2);
390        assert!(blockers.contains("bn-2"));
391        assert!(blockers.contains("bn-3"));
392    }
393
394    #[test]
395    fn related_links_do_not_block() {
396        let mut states = HashMap::new();
397        states.insert("bn-1".to_string(), state_with_related(&["bn-2"]));
398        states.insert("bn-2".to_string(), WorkItemState::new());
399
400        let graph = BlockingGraph::from_states(&states);
401
402        // Relates links are informational — bn-1 is NOT blocked.
403        assert!(!graph.is_blocked("bn-1"));
404        assert!(graph.ready_items().contains("bn-1"));
405
406        // But related link is recorded.
407        let related = graph.get_related("bn-1");
408        assert!(related.contains("bn-2"));
409    }
410
411    // -----------------------------------------------------------------------
412    // Link/Unlink via events
413    // -----------------------------------------------------------------------
414
415    #[test]
416    fn link_then_unlink_removes_blocker() {
417        let mut state = WorkItemState::new();
418        state.apply_event(&make_link_event(
419            "bn-dep",
420            "blocks",
421            1000,
422            "alice",
423            "blake3:l1",
424        ));
425        assert!(!state.blocked_by.is_empty());
426
427        state.apply_event(&make_unlink_event(
428            "bn-dep",
429            Some("blocks"),
430            2000,
431            "alice",
432            "blake3:ul1",
433        ));
434        assert!(state.blocked_by.is_empty());
435
436        let mut states = HashMap::new();
437        states.insert("bn-task".to_string(), state);
438
439        let graph = BlockingGraph::from_states(&states);
440        assert!(!graph.is_blocked("bn-task"));
441        assert!(graph.ready_items().contains("bn-task"));
442    }
443
444    #[test]
445    fn unlink_without_link_is_noop() {
446        let mut state = WorkItemState::new();
447        state.apply_event(&make_unlink_event(
448            "bn-nonexistent",
449            Some("blocks"),
450            1000,
451            "alice",
452            "blake3:ul1",
453        ));
454
455        let mut states = HashMap::new();
456        states.insert("bn-task".to_string(), state);
457
458        let graph = BlockingGraph::from_states(&states);
459        assert!(!graph.is_blocked("bn-task"));
460    }
461
462    // -----------------------------------------------------------------------
463    // OR-Set convergence for concurrent link/unlink
464    // -----------------------------------------------------------------------
465
466    #[test]
467    fn concurrent_link_and_unlink_add_wins() {
468        // Agent A adds a blocking link (tag1).
469        let mut state_a = WorkItemState::new();
470        state_a.apply_event(&make_link_event(
471            "bn-dep",
472            "blocks",
473            1000,
474            "alice",
475            "blake3:l1",
476        ));
477
478        // Agent B also starts from empty state and removes (sees no tags).
479        let state_b = WorkItemState::new();
480        // B has no tags to tombstone — so the unlink is effectively empty.
481
482        // Merge A and B — A's add survives because B never saw the tag.
483        let mut merged = state_a.clone();
484        merged.merge(&state_b);
485
486        let mut states = HashMap::new();
487        states.insert("bn-task".to_string(), merged);
488
489        let graph = BlockingGraph::from_states(&states);
490        assert!(
491            graph.is_blocked("bn-task"),
492            "add-wins: concurrent add should survive empty remove"
493        );
494    }
495
496    #[test]
497    fn concurrent_add_wins_over_remove() {
498        // Base state: bn-task blocks on bn-dep (tag1 present).
499        let mut base = WorkItemState::new();
500        base.apply_event(&make_link_event(
501            "bn-dep",
502            "blocks",
503            1000,
504            "alice",
505            "blake3:l1",
506        ));
507
508        // Agent A removes the link (tombstones tag1).
509        let mut state_a = base.clone();
510        state_a.apply_event(&make_unlink_event(
511            "bn-dep",
512            Some("blocks"),
513            2000,
514            "alice",
515            "blake3:ul1",
516        ));
517        assert!(state_a.blocked_by.is_empty());
518
519        // Agent B concurrently re-adds the link (tag2 — new tag not seen by A).
520        let mut state_b = base.clone();
521        state_b.apply_event(&make_link_event(
522            "bn-dep",
523            "blocks",
524            2000,
525            "bob",
526            "blake3:l2",
527        ));
528
529        // Merge: B's tag2 survives A's tombstone of tag1.
530        let mut merged = state_a.clone();
531        merged.merge(&state_b);
532
533        let mut states = HashMap::new();
534        states.insert("bn-task".to_string(), merged);
535
536        let graph = BlockingGraph::from_states(&states);
537        assert!(
538            graph.is_blocked("bn-task"),
539            "add-wins: B's concurrent re-add should survive A's remove"
540        );
541    }
542
543    // -----------------------------------------------------------------------
544    // Cross-goal dependencies
545    // -----------------------------------------------------------------------
546
547    #[test]
548    fn cross_goal_blocking_works() {
549        // Item in goal 1 is blocked by item in goal 2.
550        let mut states = HashMap::new();
551        states.insert(
552            "bn-goal1-task".to_string(),
553            state_with_blockers(&["bn-goal2-task"]),
554        );
555        states.insert("bn-goal2-task".to_string(), WorkItemState::new());
556
557        let graph = BlockingGraph::from_states(&states);
558
559        assert!(graph.is_blocked("bn-goal1-task"));
560        let blockers = graph.get_blockers("bn-goal1-task");
561        assert!(blockers.contains("bn-goal2-task"));
562
563        // Cross-goal blocker has no blockers itself.
564        assert!(!graph.is_blocked("bn-goal2-task"));
565    }
566
567    #[test]
568    fn cross_goal_blocker_not_in_states_still_blocks() {
569        // The blocker may not be in the states map (e.g., different shard).
570        let mut states = HashMap::new();
571        states.insert("bn-task".to_string(), state_with_blockers(&["bn-external"]));
572        // bn-external is NOT in states.
573
574        let graph = BlockingGraph::from_states(&states);
575
576        // bn-task is still blocked even though bn-external is not in the map.
577        assert!(graph.is_blocked("bn-task"));
578        assert!(!graph.ready_items().contains("bn-task"));
579    }
580
581    // -----------------------------------------------------------------------
582    // ready_items correctness
583    // -----------------------------------------------------------------------
584
585    #[test]
586    fn ready_items_excludes_blocked() {
587        let mut states = HashMap::new();
588        states.insert("bn-1".to_string(), WorkItemState::new());
589        states.insert("bn-2".to_string(), state_with_blockers(&["bn-1"]));
590        states.insert("bn-3".to_string(), WorkItemState::new());
591
592        let graph = BlockingGraph::from_states(&states);
593        let ready = graph.ready_items();
594
595        assert!(ready.contains("bn-1"));
596        assert!(!ready.contains("bn-2"));
597        assert!(ready.contains("bn-3"));
598        assert_eq!(ready.len(), 2);
599    }
600
601    #[test]
602    fn chain_blocking_all_after_first_blocked() {
603        // bn-1 ← bn-2 ← bn-3 (bn-3 blocked by bn-2, bn-2 blocked by bn-1)
604        let mut states = HashMap::new();
605        states.insert("bn-1".to_string(), WorkItemState::new());
606        states.insert("bn-2".to_string(), state_with_blockers(&["bn-1"]));
607        states.insert("bn-3".to_string(), state_with_blockers(&["bn-2"]));
608
609        let graph = BlockingGraph::from_states(&states);
610        let ready = graph.ready_items();
611
612        assert!(ready.contains("bn-1"), "bn-1 has no blockers");
613        assert!(!ready.contains("bn-2"), "bn-2 blocked by bn-1");
614        assert!(!ready.contains("bn-3"), "bn-3 blocked by bn-2");
615        assert_eq!(ready.len(), 1);
616    }
617
618    // -----------------------------------------------------------------------
619    // Standalone function tests
620    // -----------------------------------------------------------------------
621
622    #[test]
623    fn standalone_is_blocked() {
624        let mut states = HashMap::new();
625        states.insert("bn-1".to_string(), state_with_blockers(&["bn-2"]));
626        states.insert("bn-2".to_string(), WorkItemState::new());
627
628        assert!(is_blocked("bn-1", &states));
629        assert!(!is_blocked("bn-2", &states));
630        assert!(!is_blocked("bn-unknown", &states));
631    }
632
633    #[test]
634    fn standalone_get_blockers() {
635        let mut states = HashMap::new();
636        states.insert("bn-1".to_string(), state_with_blockers(&["bn-2", "bn-3"]));
637
638        let blockers = get_blockers("bn-1", &states);
639        assert_eq!(blockers.len(), 2);
640        assert!(blockers.contains("bn-2"));
641        assert!(blockers.contains("bn-3"));
642
643        // Non-existent item returns empty.
644        assert!(get_blockers("bn-unknown", &states).is_empty());
645    }
646
647    #[test]
648    fn standalone_ready_items() {
649        let mut states = HashMap::new();
650        states.insert("bn-1".to_string(), WorkItemState::new());
651        states.insert("bn-2".to_string(), state_with_blockers(&["bn-1"]));
652        states.insert("bn-3".to_string(), WorkItemState::new());
653
654        let ready = ready_items(&states);
655        let ready_set: HashSet<_> = ready.iter().map(String::as_str).collect();
656
657        assert!(ready_set.contains("bn-1"));
658        assert!(!ready_set.contains("bn-2"));
659        assert!(ready_set.contains("bn-3"));
660    }
661
662    // -----------------------------------------------------------------------
663    // get_blockers for unknown items
664    // -----------------------------------------------------------------------
665
666    #[test]
667    fn get_blockers_for_unknown_item_returns_empty() {
668        let states: HashMap<String, WorkItemState> = HashMap::new();
669        let graph = BlockingGraph::from_states(&states);
670
671        assert!(graph.get_blockers("bn-unknown").is_empty());
672        assert!(graph.get_related("bn-unknown").is_empty());
673        assert!(!graph.is_blocked("bn-unknown"));
674    }
675
676    // -----------------------------------------------------------------------
677    // Relates and blocking mixed
678    // -----------------------------------------------------------------------
679
680    #[test]
681    fn item_with_both_blocking_and_relates() {
682        let mut state = WorkItemState::new();
683        state.apply_event(&make_link_event(
684            "bn-blocker",
685            "blocks",
686            1000,
687            "alice",
688            "blake3:l1",
689        ));
690        state.apply_event(&make_link_event(
691            "bn-related",
692            "related_to",
693            1001,
694            "alice",
695            "blake3:l2",
696        ));
697
698        let mut states = HashMap::new();
699        states.insert("bn-task".to_string(), state);
700
701        let graph = BlockingGraph::from_states(&states);
702
703        assert!(graph.is_blocked("bn-task"));
704        assert!(graph.get_blockers("bn-task").contains("bn-blocker"));
705        assert!(graph.get_related("bn-task").contains("bn-related"));
706        assert!(!graph.ready_items().contains("bn-task"));
707    }
708
709    // -----------------------------------------------------------------------
710    // blocked_by type aliases
711    // -----------------------------------------------------------------------
712
713    #[test]
714    fn blocked_by_type_alias_works() {
715        // "blocked_by" is another valid link_type that should register as a blocker
716        let mut state = WorkItemState::new();
717        state.apply_event(&make_link_event(
718            "bn-dep",
719            "blocked_by",
720            1000,
721            "alice",
722            "blake3:l1",
723        ));
724
725        let mut states = HashMap::new();
726        states.insert("bn-task".to_string(), state);
727
728        let graph = BlockingGraph::from_states(&states);
729        assert!(graph.is_blocked("bn-task"));
730    }
731
732    #[test]
733    fn related_type_alias_works() {
734        // "related" is another valid link_type for relates links
735        let mut state = WorkItemState::new();
736        state.apply_event(&make_link_event(
737            "bn-dep",
738            "related",
739            1000,
740            "alice",
741            "blake3:l1",
742        ));
743
744        let mut states = HashMap::new();
745        states.insert("bn-task".to_string(), state);
746
747        let graph = BlockingGraph::from_states(&states);
748        assert!(!graph.is_blocked("bn-task"));
749        assert!(graph.get_related("bn-task").contains("bn-dep"));
750    }
751}