From 57f377f74b6059e07f42a8a4c8c40335d6c9d684 Mon Sep 17 00:00:00 2001 From: Thomas Krause Date: Wed, 7 Oct 2026 16:51:16 +0200 Subject: [PATCH 1/9] Update graphviz-rust --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a7bc24c2..9afb9bef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ git2 = { version = "0.20.2", default-features = false } glob = "0.3" graphannis = "4.2.0" graphannis-core = "4.2.0" -graphviz-rust = "0.9.7" +graphviz-rust = "0.9.9" indicatif = "0.17" itertools = "0.12" lazy_static = "1.4.0" From c6b94dc6ad0572f102c687e2874fdaa2fe054485 Mon Sep 17 00:00:00 2001 From: Thomas Krause Date: Wed, 7 Oct 2026 16:51:46 +0200 Subject: [PATCH 2/9] Add method that consumes a ProgressReporter and creates a new one with a known work --- src/progress.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/progress.rs b/src/progress.rs index 1d0a7cf8..944aece0 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -37,6 +37,22 @@ impl ProgressReporter { Ok(reporter) } + /// Converts an existing `ProgressReporter` to a new one with the given amount of total work. + /// Any progress information is removed and the work starts at 0 again. + pub fn with_total_work( + self: Self, + total_work: usize, + ) -> Result { + let reporter = ProgressReporter { + state: self.state, + step_id: self.step_id, + total_work: Some(total_work), + }; + // Send a first status report so any listener can get the total number of steps to perform + reporter.worked(0)?; + Ok(reporter) + } + pub fn new_unknown_total_work( tx: Option, step_id: StepID, From b8ceaf6ce6f57d369d9b33a1ed6bfd68647e9556 Mon Sep 17 00:00:00 2001 From: Thomas Krause Date: Wed, 7 Oct 2026 16:52:56 +0200 Subject: [PATCH 3/9] Break loop if a graph has cycles and we already handled a node in the path --- src/manipulator/visualize.rs | 71 +++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/src/manipulator/visualize.rs b/src/manipulator/visualize.rs index db81f938..4f257511 100644 --- a/src/manipulator/visualize.rs +++ b/src/manipulator/visualize.rs @@ -1,6 +1,7 @@ use super::Manipulator; use crate::{ StepID, + progress::ProgressReporter, util::{CorpusGraphHelper, token_helper::TokenHelper}, }; use anyhow::{Context, Result}; @@ -139,7 +140,11 @@ impl Default for Visualize { } impl Visualize { - fn create_graph(&self, graph: &AnnotationGraph) -> Result { + fn create_graph( + &self, + graph: &AnnotationGraph, + mut progress: ProgressReporter, + ) -> Result { let mut output = Graph::DiGraph { id: Id::Plain("G".to_string()), strict: false, @@ -149,12 +154,17 @@ impl Visualize { let token_helper = TokenHelper::new(graph)?; let parent_id = self.get_root_node_name(graph)?; + let all_token = token_helper.get_ordered_token(&parent_id, None)?; let included_token = if self.limit_tokens { all_token.into_iter().take(self.token_limit).collect_vec() } else { all_token }; + progress.info(format!( + "visualizing {} token from {parent_id}", + included_token.len() + ))?; let mut subgraph = subgraph!("token"; attr!("rank", "same")); for t in included_token.iter() { @@ -171,21 +181,38 @@ impl Visualize { let all_edge_container = UnionEdgeContainer::new(all_gs.iter().map(|gs| gs.as_edgecontainer()).collect_vec()); - let mut included_nodes: HashSet = - included_token.iter().copied().collect(); + let mut included_nodes = HashSet::new(); + + progress = progress.with_total_work(included_token.len())?; for t in included_token { - for step in dfs::CycleSafeDFS::new(&all_edge_container, t, 1, usize::MAX) { - let n = step?.node; - if !token_helper.is_token(n)? && included_nodes.insert(n) { - output.add_stmt(self.create_node_stmt(n, graph)?); + if included_nodes.insert(t) { + for step in dfs::CycleSafeDFS::new(&all_edge_container, t, 1, usize::MAX) { + let step = step?; + let n = step.node; + + if included_nodes.insert(n) { + if !token_helper.is_token(n)? { + output.add_stmt(self.create_node_stmt(n, graph)?); + } + } else { + // Abort the DFS, since we if we added this node already we also have seen all ancestor nodes of it + break; + } } - } - for step in dfs::CycleSafeDFS::new_inverse(&all_edge_container, t, 1, usize::MAX) { - let n = step?.node; - if !token_helper.is_token(n)? && included_nodes.insert(n) { - output.add_stmt(self.create_node_stmt(n, graph)?); + for step in dfs::CycleSafeDFS::new_inverse(&all_edge_container, t, 1, usize::MAX) { + let n = step?.node; + if included_nodes.insert(n) { + if !token_helper.is_token(n)? { + output.add_stmt(self.create_node_stmt(n, graph)?); + } + } else { + // Abort the DFS, since we if we added this node already we also have seen all ancestor nodes of it + break; + } } } + + progress.worked(1)?; } // Add all datasource nodes if they are connected to the included documents have not been already added let part_of_gs = graph @@ -276,7 +303,14 @@ impl Visualize { let anno_string = annos .into_iter() - .map(|a| format!("{}:{}={}", a.key.ns, a.key.name, a.val)) + .map(|a| { + format!( + "{}:{}={}", + a.key.ns, + a.key.name, + a.val.replace("\"", "\\\"") + ) + }) .join("\\n"); let label = format!("\"{node_name}\\n \\n{anno_string}\""); @@ -358,12 +392,13 @@ impl Manipulator for Visualize { &self, graph: &mut graphannis::AnnotationGraph, workflow_directory: &std::path::Path, - _step_id: StepID, - _tx: Option, + step_id: StepID, + tx: Option, ) -> Result<(), Box> { - // let progress = ProgressReporter::new_unknown_total_work(tx, step_id)?; - - let output = self.create_graph(graph)?; + let output = self.create_graph( + graph, + ProgressReporter::new_unknown_total_work(tx, step_id)?, + )?; if let Some(file_path) = &self.output_dot { let graph_dot = output.print(&mut PrinterContext::default()); From 01134009c9472f961ebc2da8334bbd322e3e4c7d Mon Sep 17 00:00:00 2001 From: Thomas Krause Date: Wed, 7 Oct 2026 16:59:15 +0200 Subject: [PATCH 4/9] Always put annis:tok first in the list of displayed annotations --- src/manipulator/visualize.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/manipulator/visualize.rs b/src/manipulator/visualize.rs index 4f257511..e13e36c1 100644 --- a/src/manipulator/visualize.rs +++ b/src/manipulator/visualize.rs @@ -2,7 +2,10 @@ use super::Manipulator; use crate::{ StepID, progress::ProgressReporter, - util::{CorpusGraphHelper, token_helper::TokenHelper}, + util::{ + CorpusGraphHelper, + token_helper::{TOKEN_KEY, TokenHelper}, + }, }; use anyhow::{Context, Result}; use facet::Facet; @@ -295,13 +298,21 @@ impl Visualize { .unwrap_or_else(|| Cow::Owned(n.to_string())); let annos = input.get_node_annos().get_annotations_for_item(&n)?; - let annos = annos - .into_iter() - .filter(|a| &a.key != NODE_NAME_KEY.as_ref()) - .sorted() - .collect_vec(); - let anno_string = annos + let mut displayed_annos = Vec::new(); + // if annis::tok is part of the annotations, put it at the beginning of the list + if let Some(tok_anno) = annos.iter().filter(|a| &a.key == TOKEN_KEY.as_ref()).next() { + displayed_annos.push(tok_anno.clone()); + } + // Add all remaining annotations + displayed_annos.extend( + annos + .into_iter() + .filter(|a| &a.key != NODE_NAME_KEY.as_ref() && &a.key != TOKEN_KEY.as_ref()) + .sorted(), + ); + + let anno_string = displayed_annos .into_iter() .map(|a| { format!( From a5ee8bc33e963a5ee80840534838a7750cfb6ad9 Mon Sep 17 00:00:00 2001 From: Thomas Krause Date: Wed, 7 Oct 2026 18:00:40 +0200 Subject: [PATCH 5/9] Add changelog entry for the speedup --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8c5dd2..7f6677dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - graph op `visualizer`: documentation for specifying a document as root was wrong +- graph op `visualizer`: speed up execution by skipping already processed tokens + and nodes when the graph has cycles ## [0.60.0] - 2026-10-06 From fb768626df6912c95772599a6f09a4dfa611a240 Mon Sep 17 00:00:00 2001 From: Thomas Krause Date: Wed, 7 Oct 2026 18:16:00 +0200 Subject: [PATCH 6/9] Fix clippy issues --- src/manipulator/visualize.rs | 2 +- src/progress.rs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/manipulator/visualize.rs b/src/manipulator/visualize.rs index e13e36c1..04d27cc9 100644 --- a/src/manipulator/visualize.rs +++ b/src/manipulator/visualize.rs @@ -301,7 +301,7 @@ impl Visualize { let mut displayed_annos = Vec::new(); // if annis::tok is part of the annotations, put it at the beginning of the list - if let Some(tok_anno) = annos.iter().filter(|a| &a.key == TOKEN_KEY.as_ref()).next() { + if let Some(tok_anno) = annos.iter().find(|a| &a.key == TOKEN_KEY.as_ref()) { displayed_annos.push(tok_anno.clone()); } // Add all remaining annotations diff --git a/src/progress.rs b/src/progress.rs index 944aece0..002a4f9f 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -39,10 +39,7 @@ impl ProgressReporter { /// Converts an existing `ProgressReporter` to a new one with the given amount of total work. /// Any progress information is removed and the work starts at 0 again. - pub fn with_total_work( - self: Self, - total_work: usize, - ) -> Result { + pub fn with_total_work(self, total_work: usize) -> Result { let reporter = ProgressReporter { state: self.state, step_id: self.step_id, From cdfb1e1fa0d7cf4fba24d72d41ad8a68a18630f6 Mon Sep 17 00:00:00 2001 From: Thomas Krause Date: Wed, 7 Oct 2026 18:26:41 +0200 Subject: [PATCH 7/9] Revert the break hack, because we seem to be missing some edges in that case --- CHANGELOG.md | 1 - ...alize__tests__dot_single_sentence_full.snap | 16 ++++++++-------- ...lize__tests__dot_single_sentence_limit.snap | 8 ++++---- src/manipulator/visualize.rs | 18 ++++-------------- 4 files changed, 16 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6677dd..c50c6267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - graph op `visualizer`: documentation for specifying a document as root was wrong - graph op `visualizer`: speed up execution by skipping already processed tokens - and nodes when the graph has cycles ## [0.60.0] - 2026-10-06 diff --git a/src/manipulator/snapshots/annatto__manipulator__visualize__tests__dot_single_sentence_full.snap b/src/manipulator/snapshots/annatto__manipulator__visualize__tests__dot_single_sentence_full.snap index 88c54c44..3cf74c83 100644 --- a/src/manipulator/snapshots/annatto__manipulator__visualize__tests__dot_single_sentence_full.snap +++ b/src/manipulator/snapshots/annatto__manipulator__visualize__tests__dot_single_sentence_full.snap @@ -5,14 +5,14 @@ expression: result_dot digraph G { subgraph token { rank=same - 6[shape=box label="single_sentence/zossen#t1\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=ART\nannis:tok=Die"] - 7[shape=box label="single_sentence/zossen#t2\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NN\nannis:tok=Jugendlichen\nannis:tok-whitespace-before= "] - 9[shape=box label="single_sentence/zossen#t3\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=APPR\nannis:tok=in\nannis:tok-whitespace-before= "] - 10[shape=box label="single_sentence/zossen#t4\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NE\nannis:tok=Zossen\nannis:tok-whitespace-before= "] - 11[shape=box label="single_sentence/zossen#t5\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=VMFIN\nannis:tok=wollen\nannis:tok-whitespace-before= "] - 13[shape=box label="single_sentence/zossen#t6\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=ART\nannis:tok=ein\nannis:tok-whitespace-before= "] - 14[shape=box label="single_sentence/zossen#t7\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NN\nannis:tok=Musikcafé\nannis:tok-whitespace-before= "] - 15[shape=box label="single_sentence/zossen#t8\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=$.\nannis:tok=.\nannis:tok-whitespace-before= "] + 6[shape=box label="single_sentence/zossen#t1\n \nannis:tok=Die\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=ART"] + 7[shape=box label="single_sentence/zossen#t2\n \nannis:tok=Jugendlichen\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NN\nannis:tok-whitespace-before= "] + 9[shape=box label="single_sentence/zossen#t3\n \nannis:tok=in\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=APPR\nannis:tok-whitespace-before= "] + 10[shape=box label="single_sentence/zossen#t4\n \nannis:tok=Zossen\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NE\nannis:tok-whitespace-before= "] + 11[shape=box label="single_sentence/zossen#t5\n \nannis:tok=wollen\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=VMFIN\nannis:tok-whitespace-before= "] + 13[shape=box label="single_sentence/zossen#t6\n \nannis:tok=ein\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=ART\nannis:tok-whitespace-before= "] + 14[shape=box label="single_sentence/zossen#t7\n \nannis:tok=Musikcafé\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NN\nannis:tok-whitespace-before= "] + 15[shape=box label="single_sentence/zossen#t8\n \nannis:tok=.\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=$.\nannis:tok-whitespace-before= "] } 2[shape=box label="single_sentence/zossen#text\n \nannis:node_type=datasource"] 1[shape=box label="single_sentence/zossen\n \nannis:doc=zossen\nannis:node_type=corpus"] diff --git a/src/manipulator/snapshots/annatto__manipulator__visualize__tests__dot_single_sentence_limit.snap b/src/manipulator/snapshots/annatto__manipulator__visualize__tests__dot_single_sentence_limit.snap index 262d431d..7b748a0b 100644 --- a/src/manipulator/snapshots/annatto__manipulator__visualize__tests__dot_single_sentence_limit.snap +++ b/src/manipulator/snapshots/annatto__manipulator__visualize__tests__dot_single_sentence_limit.snap @@ -5,10 +5,10 @@ expression: result_dot digraph G { subgraph token { rank=same - 6[shape=box label="single_sentence/zossen#t1\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=ART\nannis:tok=Die"] - 7[shape=box label="single_sentence/zossen#t2\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NN\nannis:tok=Jugendlichen\nannis:tok-whitespace-before= "] - 9[shape=box label="single_sentence/zossen#t3\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=APPR\nannis:tok=in\nannis:tok-whitespace-before= "] - 10[shape=box label="single_sentence/zossen#t4\n \nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NE\nannis:tok=Zossen\nannis:tok-whitespace-before= "] + 6[shape=box label="single_sentence/zossen#t1\n \nannis:tok=Die\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=ART"] + 7[shape=box label="single_sentence/zossen#t2\n \nannis:tok=Jugendlichen\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NN\nannis:tok-whitespace-before= "] + 9[shape=box label="single_sentence/zossen#t3\n \nannis:tok=in\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=APPR\nannis:tok-whitespace-before= "] + 10[shape=box label="single_sentence/zossen#t4\n \nannis:tok=Zossen\nannis:layer=default_layer\nannis:node_type=node\ndefault_ns:pos=NE\nannis:tok-whitespace-before= "] } 2[shape=box label="single_sentence/zossen#text\n \nannis:node_type=datasource"] 1[shape=box label="single_sentence/zossen\n \nannis:doc=zossen\nannis:node_type=corpus"] diff --git a/src/manipulator/visualize.rs b/src/manipulator/visualize.rs index 04d27cc9..e81f4208 100644 --- a/src/manipulator/visualize.rs +++ b/src/manipulator/visualize.rs @@ -193,24 +193,14 @@ impl Visualize { let step = step?; let n = step.node; - if included_nodes.insert(n) { - if !token_helper.is_token(n)? { - output.add_stmt(self.create_node_stmt(n, graph)?); - } - } else { - // Abort the DFS, since we if we added this node already we also have seen all ancestor nodes of it - break; + if !token_helper.is_token(n)? && included_nodes.insert(n) { + output.add_stmt(self.create_node_stmt(n, graph)?); } } for step in dfs::CycleSafeDFS::new_inverse(&all_edge_container, t, 1, usize::MAX) { let n = step?.node; - if included_nodes.insert(n) { - if !token_helper.is_token(n)? { - output.add_stmt(self.create_node_stmt(n, graph)?); - } - } else { - // Abort the DFS, since we if we added this node already we also have seen all ancestor nodes of it - break; + if !token_helper.is_token(n)? && included_nodes.insert(n) { + output.add_stmt(self.create_node_stmt(n, graph)?); } } } From 5e8cebc8e032d30900b20e26bf858992b518ca36 Mon Sep 17 00:00:00 2001 From: Thomas Krause Date: Wed, 7 Oct 2026 18:48:44 +0200 Subject: [PATCH 8/9] Ignore pointing relations when finding reachable nodes. --- CHANGELOG.md | 1 + src/manipulator/visualize.rs | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c50c6267..bc7f7342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - graph op `visualizer`: documentation for specifying a document as root was wrong - graph op `visualizer`: speed up execution by skipping already processed tokens + and ignoring pointing relations to find reachable nodes ## [0.60.0] - 2026-10-06 diff --git a/src/manipulator/visualize.rs b/src/manipulator/visualize.rs index e81f4208..b1ce23fd 100644 --- a/src/manipulator/visualize.rs +++ b/src/manipulator/visualize.rs @@ -177,19 +177,27 @@ impl Visualize { // Add all other nodes that are somehow connected to the included token and the document let all_components = graph.get_all_components(None, None); - let all_gs = all_components + + let all_non_pointing_gs = all_components .iter() + .filter(|c| c.get_type() != AnnotationComponentType::Pointing) .filter_map(|c| graph.get_graphstorage(c)) .collect_vec(); - let all_edge_container = - UnionEdgeContainer::new(all_gs.iter().map(|gs| gs.as_edgecontainer()).collect_vec()); + + // Iterate over all non-pointing components to find connected nodes + let edge_container = UnionEdgeContainer::new( + all_non_pointing_gs + .iter() + .map(|gs| gs.as_edgecontainer()) + .collect_vec(), + ); let mut included_nodes = HashSet::new(); progress = progress.with_total_work(included_token.len())?; for t in included_token { if included_nodes.insert(t) { - for step in dfs::CycleSafeDFS::new(&all_edge_container, t, 1, usize::MAX) { + for step in dfs::CycleSafeDFS::new(&edge_container, t, 1, usize::MAX) { let step = step?; let n = step.node; @@ -197,7 +205,7 @@ impl Visualize { output.add_stmt(self.create_node_stmt(n, graph)?); } } - for step in dfs::CycleSafeDFS::new_inverse(&all_edge_container, t, 1, usize::MAX) { + for step in dfs::CycleSafeDFS::new_inverse(&edge_container, t, 1, usize::MAX) { let n = step?.node; if !token_helper.is_token(n)? && included_nodes.insert(n) { output.add_stmt(self.create_node_stmt(n, graph)?); From 6b4571033f7867e6fa1ed02d5f8ebf30a6668b43 Mon Sep 17 00:00:00 2001 From: Thomas Krause Date: Wed, 7 Oct 2026 18:57:56 +0200 Subject: [PATCH 9/9] Revert back to unknown work when visualizer output is written --- src/manipulator/visualize.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/manipulator/visualize.rs b/src/manipulator/visualize.rs index b1ce23fd..b6e337cf 100644 --- a/src/manipulator/visualize.rs +++ b/src/manipulator/visualize.rs @@ -406,15 +406,24 @@ impl Manipulator for Visualize { ) -> Result<(), Box> { let output = self.create_graph( graph, - ProgressReporter::new_unknown_total_work(tx, step_id)?, + ProgressReporter::new_unknown_total_work(tx.clone(), step_id.clone())?, )?; + let progress = ProgressReporter::new_unknown_total_work(tx, step_id)?; if let Some(file_path) = &self.output_dot { + progress.info(format!( + "writing visualizer output DOT file {}", + file_path.to_string_lossy() + ))?; let graph_dot = output.print(&mut PrinterContext::default()); std::fs::write(workflow_directory.join(file_path), graph_dot)?; } if let Some(file_path) = &self.output_svg { + progress.info(format!( + "writing visualizer output SVG file {}", + file_path.to_string_lossy() + ))?; let graph_svg = exec( output, &mut PrinterContext::default(),