From 3a1e2ae6437d4abdf4317bedcf8b9ab65ff2a316 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 05:37:25 +0000 Subject: [PATCH 1/4] Add SeqSee output format for charting Add a `SeqSeeBackend` implementing the charting `Backend` trait that emits JSON conforming to the SeqSee schema (https://github.com/JoeyBF/SeqSee). SeqSee is a generic spectral sequence visualizer that consumes such JSON and renders a self-contained interactive HTML chart. The backend buffers nodes and edges in memory (since the JSON groups all nodes and all edges, whereas the trait callbacks interleave them) and writes the document on drop, mirroring how the SVG/TikZ backends finalize output. Grid lines and axis labels are no-ops because SeqSee draws its own grid from the chart dimensions. Differentials (styles matching `d`) are colored to match the other backends; every referenced style is registered as an attribute alias so the output validates against the schema. The `chart` example now prompts for the output format (svg/tikz/seqsee) and dispatches to the corresponding backend. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CHkHh9W7nnkiM2Z6fhdHPw --- ext/crates/sseq/src/charting/mod.rs | 2 + ext/crates/sseq/src/charting/seqsee.rs | 280 +++++++++++++++++++++++++ ext/examples/chart.rs | 26 ++- 3 files changed, 300 insertions(+), 8 deletions(-) create mode 100644 ext/crates/sseq/src/charting/seqsee.rs diff --git a/ext/crates/sseq/src/charting/mod.rs b/ext/crates/sseq/src/charting/mod.rs index 4dde744944..2b0d0821dc 100644 --- a/ext/crates/sseq/src/charting/mod.rs +++ b/ext/crates/sseq/src/charting/mod.rs @@ -2,9 +2,11 @@ use std::fmt::Display; use crate::coordinates::{Bidegree, BidegreeGenerator}; +pub mod seqsee; pub mod svg; pub mod tikz; +pub use seqsee::SeqSeeBackend; pub use svg::SvgBackend; pub use tikz::TikzBackend; diff --git a/ext/crates/sseq/src/charting/seqsee.rs b/ext/crates/sseq/src/charting/seqsee.rs new file mode 100644 index 0000000000..5d88fed225 --- /dev/null +++ b/ext/crates/sseq/src/charting/seqsee.rs @@ -0,0 +1,280 @@ +use std::{collections::BTreeSet, fmt::Display, io}; + +use serde_json::{Map, Value, json}; + +use crate::{ + charting::{Backend, Orientation}, + coordinates::{Bidegree, BidegreeGenerator}, +}; + +/// A [`Backend`] that emits [SeqSee](https://github.com/JoeyBF/SeqSee) JSON. +/// +/// SeqSee is a generic visualization tool for spectral sequences. It consumes a JSON file +/// conforming to its [schema] and produces a self-contained, interactive HTML chart. This backend +/// targets that schema so that the charts produced elsewhere in this crate can be rendered by +/// SeqSee. +/// +/// The document is assembled in memory and written out when the backend is dropped, since the JSON +/// object must group all nodes together and all edges together, whereas the [`Backend`] callbacks +/// interleave them. +/// +/// [schema]: https://raw.githubusercontent.com/JoeyBF/SeqSee/refs/heads/master/seqsee/input_schema.json +pub struct SeqSeeBackend { + out: T, + max: Bidegree, + /// Map from node id to its SeqSee node object. + nodes: Map, + /// The list of SeqSee edge objects. + edges: Vec, + /// The set of style names referenced by edges. Each becomes an attribute alias in the header so + /// that the edges referencing it validate against the schema. + styles: BTreeSet, +} + +impl SeqSeeBackend { + pub fn new(out: T) -> Self { + Self { + out, + max: Bidegree::zero(), + nodes: Map::new(), + edges: Vec::new(), + styles: BTreeSet::new(), + } + } + + /// The identifier used for the `idx`-th generator in bidegree `(x, y)`. + /// + /// This must agree between [`Self::node`] (which creates the nodes) and [`Self::structline`] + /// (which references them as edge endpoints). + fn node_id(x: i32, y: i32, idx: usize) -> String { + format!("{x},{y},{idx}") + } +} + +/// Whether a style name denotes a differential, i.e. it is `d` followed by a page number. +/// +/// Differentials are given a distinct color to match the other backends in this crate. +fn is_differential(style: &str) -> bool { + style + .strip_prefix('d') + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) +} + +impl Backend for SeqSeeBackend { + type Error = io::Error; + + const EXT: &'static str = "json"; + + fn header(&mut self, max: Bidegree) -> Result<(), Self::Error> { + self.max = max; + Ok(()) + } + + // SeqSee draws its own grid based on the chart dimensions in the header, so there is nothing to + // emit for the grid lines. + fn line(&mut self, _start: Bidegree, _end: Bidegree, _style: &str) -> Result<(), Self::Error> { + Ok(()) + } + + // SeqSee draws its own axis labels, so there is nothing to emit here. + fn text( + &mut self, + _b: Bidegree, + _content: impl Display, + _orientation: Orientation, + ) -> Result<(), Self::Error> { + Ok(()) + } + + fn node(&mut self, b: Bidegree, n: usize) -> Result<(), Self::Error> { + if n == 0 || b.x() > self.max.x() || b.y() > self.max.y() { + return Ok(()); + } + + for k in 0..n { + let id = Self::node_id(b.x(), b.y(), k); + self.nodes.insert( + id, + json!({ + "x": b.x(), + "y": b.y(), + "position": k, + }), + ); + } + Ok(()) + } + + fn structline( + &mut self, + source: BidegreeGenerator, + target: BidegreeGenerator, + style: Option<&str>, + ) -> Result<(), Self::Error> { + if source.x() > self.max.x() + || source.y() > self.max.y() + || target.x() > self.max.x() + || target.y() > self.max.y() + { + return Ok(()); + } + + let mut edge = Map::new(); + edge.insert( + "source".to_string(), + Value::String(Self::node_id(source.x(), source.y(), source.idx())), + ); + edge.insert( + "target".to_string(), + Value::String(Self::node_id(target.x(), target.y(), target.idx())), + ); + if let Some(style) = style { + self.styles.insert(style.to_string()); + edge.insert("attributes".to_string(), json!([style])); + } + self.edges.push(Value::Object(edge)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use expect_test::expect; + + use super::*; + + #[test] + fn test_seqsee_output() { + let mut buf: Vec = Vec::new(); + { + let mut backend = SeqSeeBackend::new(&mut buf); + // `init` draws the grid and axis labels via `line`/`text`, which are no-ops here. + backend.init(Bidegree::x_y(2, 2)).unwrap(); + + backend.node(Bidegree::x_y(0, 0), 1).unwrap(); + backend.node(Bidegree::x_y(0, 1), 1).unwrap(); + backend.node(Bidegree::x_y(1, 1), 1).unwrap(); + + // A multiplication by `h0` (a filtration-one product). + backend + .structline( + BidegreeGenerator::new(Bidegree::x_y(0, 0), 0), + BidegreeGenerator::new(Bidegree::x_y(0, 1), 0), + Some("h0"), + ) + .unwrap(); + // A `d2` differential. + backend + .structline( + BidegreeGenerator::new(Bidegree::x_y(1, 1), 0), + BidegreeGenerator::new(Bidegree::x_y(0, 1), 0), + Some("d2"), + ) + .unwrap(); + // Out of bounds: dropped silently. + backend.node(Bidegree::x_y(5, 5), 1).unwrap(); + } + + expect![[r#" + { + "edges": [ + { + "attributes": [ + "h0" + ], + "source": "0,0,0", + "target": "0,1,0" + }, + { + "attributes": [ + "d2" + ], + "source": "1,1,0", + "target": "0,1,0" + } + ], + "header": { + "aliases": { + "attributes": { + "d2": [ + { + "color": "blue" + } + ], + "h0": [] + } + }, + "chart": { + "height": { + "max": 2, + "min": 0 + }, + "width": { + "max": 2, + "min": 0 + } + } + }, + "nodes": { + "0,0,0": { + "position": 0, + "x": 0, + "y": 0 + }, + "0,1,0": { + "position": 0, + "x": 0, + "y": 1 + }, + "1,1,0": { + "position": 0, + "x": 1, + "y": 1 + } + } + } + "#]] + .assert_eq(std::str::from_utf8(&buf).unwrap()); + } + + #[test] + fn test_is_differential() { + assert!(is_differential("d2")); + assert!(is_differential("d17")); + assert!(!is_differential("d")); + assert!(!is_differential("h0")); + assert!(!is_differential("delta")); + } +} + +impl Drop for SeqSeeBackend { + fn drop(&mut self) { + // Register every referenced style as an attribute alias so that the edges validate. + let mut attributes = Map::new(); + for style in &self.styles { + let attr = if is_differential(style) { + json!([{ "color": "blue" }]) + } else { + json!([]) + }; + attributes.insert(style.clone(), attr); + } + + let document = json!({ + "header": { + "chart": { + "width": { "min": 0, "max": self.max.x() }, + "height": { "min": 0, "max": self.max.y() }, + }, + "aliases": { + "attributes": attributes, + }, + }, + "nodes": std::mem::take(&mut self.nodes), + "edges": std::mem::take(&mut self.edges), + }); + + let _ = serde_json::to_writer_pretty(&mut self.out, &document); + let _ = writeln!(self.out); + } +} diff --git a/ext/examples/chart.rs b/ext/examples/chart.rs index 5cb02c9258..872cca320d 100644 --- a/ext/examples/chart.rs +++ b/ext/examples/chart.rs @@ -3,13 +3,22 @@ use ext::{ chain_complex::{ChainComplex, FreeChainComplex}, utils::query_module, }; -use sseq::charting::SvgBackend; +use sseq::charting::{SeqSeeBackend, SvgBackend, TikzBackend}; fn main() -> anyhow::Result<()> { ext::utils::init_logging()?; let resolution = query_module(None, false)?; + let format = query::with_default("Output format (svg/tikz/seqsee)", "svg", |x| { + match x { + "svg" | "tikz" | "seqsee" => Ok(x.to_string()), + _ => Err(format!( + "unknown format '{x}'; expected one of svg, tikz, seqsee" + )), + } + }); + let sseq = resolution.to_sseq(); let products: Vec<_> = resolution .algebra() @@ -18,12 +27,13 @@ fn main() -> anyhow::Result<()> { .map(|(name, op_deg, op_idx)| (name, resolution.filtration_one_products(op_deg, op_idx))) .collect(); - sseq.write_to_graph( - SvgBackend::new(std::io::stdout()), - 2, - false, - products.iter(), - |_| Ok(()), - )?; + let out = std::io::stdout(); + match format.as_str() { + "tikz" => sseq.write_to_graph(TikzBackend::new(out), 2, false, products.iter(), |_| Ok(()))?, + "seqsee" => { + sseq.write_to_graph(SeqSeeBackend::new(out), 2, false, products.iter(), |_| Ok(()))? + } + _ => sseq.write_to_graph(SvgBackend::new(out), 2, false, products.iter(), |_| Ok(()))?, + } Ok(()) } From 0cd6447ee41b3eb8787b87d2c0adda33f7249eaa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 05:49:22 +0000 Subject: [PATCH 2/4] Satisfy nightly lint (fmt + items-after-test-module) Move the `Drop` impl above the test module to satisfy `clippy::items_after_test_module`, and apply nightly rustfmt formatting to the chart example. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CHkHh9W7nnkiM2Z6fhdHPw --- ext/crates/sseq/src/charting/seqsee.rs | 64 +++++++++++++------------- ext/examples/chart.rs | 20 ++++---- 2 files changed, 43 insertions(+), 41 deletions(-) diff --git a/ext/crates/sseq/src/charting/seqsee.rs b/ext/crates/sseq/src/charting/seqsee.rs index 5d88fed225..2e9551f75e 100644 --- a/ext/crates/sseq/src/charting/seqsee.rs +++ b/ext/crates/sseq/src/charting/seqsee.rs @@ -137,6 +137,38 @@ impl Backend for SeqSeeBackend { } } +impl Drop for SeqSeeBackend { + fn drop(&mut self) { + // Register every referenced style as an attribute alias so that the edges validate. + let mut attributes = Map::new(); + for style in &self.styles { + let attr = if is_differential(style) { + json!([{ "color": "blue" }]) + } else { + json!([]) + }; + attributes.insert(style.clone(), attr); + } + + let document = json!({ + "header": { + "chart": { + "width": { "min": 0, "max": self.max.x() }, + "height": { "min": 0, "max": self.max.y() }, + }, + "aliases": { + "attributes": attributes, + }, + }, + "nodes": std::mem::take(&mut self.nodes), + "edges": std::mem::take(&mut self.edges), + }); + + let _ = serde_json::to_writer_pretty(&mut self.out, &document); + let _ = writeln!(self.out); + } +} + #[cfg(test)] mod tests { use expect_test::expect; @@ -246,35 +278,3 @@ mod tests { assert!(!is_differential("delta")); } } - -impl Drop for SeqSeeBackend { - fn drop(&mut self) { - // Register every referenced style as an attribute alias so that the edges validate. - let mut attributes = Map::new(); - for style in &self.styles { - let attr = if is_differential(style) { - json!([{ "color": "blue" }]) - } else { - json!([]) - }; - attributes.insert(style.clone(), attr); - } - - let document = json!({ - "header": { - "chart": { - "width": { "min": 0, "max": self.max.x() }, - "height": { "min": 0, "max": self.max.y() }, - }, - "aliases": { - "attributes": attributes, - }, - }, - "nodes": std::mem::take(&mut self.nodes), - "edges": std::mem::take(&mut self.edges), - }); - - let _ = serde_json::to_writer_pretty(&mut self.out, &document); - let _ = writeln!(self.out); - } -} diff --git a/ext/examples/chart.rs b/ext/examples/chart.rs index 872cca320d..a720c899a8 100644 --- a/ext/examples/chart.rs +++ b/ext/examples/chart.rs @@ -10,13 +10,11 @@ fn main() -> anyhow::Result<()> { let resolution = query_module(None, false)?; - let format = query::with_default("Output format (svg/tikz/seqsee)", "svg", |x| { - match x { - "svg" | "tikz" | "seqsee" => Ok(x.to_string()), - _ => Err(format!( - "unknown format '{x}'; expected one of svg, tikz, seqsee" - )), - } + let format = query::with_default("Output format (svg/tikz/seqsee)", "svg", |x| match x { + "svg" | "tikz" | "seqsee" => Ok(x.to_string()), + _ => Err(format!( + "unknown format '{x}'; expected one of svg, tikz, seqsee" + )), }); let sseq = resolution.to_sseq(); @@ -29,9 +27,13 @@ fn main() -> anyhow::Result<()> { let out = std::io::stdout(); match format.as_str() { - "tikz" => sseq.write_to_graph(TikzBackend::new(out), 2, false, products.iter(), |_| Ok(()))?, + "tikz" => { + sseq.write_to_graph(TikzBackend::new(out), 2, false, products.iter(), |_| Ok(()))? + } "seqsee" => { - sseq.write_to_graph(SeqSeeBackend::new(out), 2, false, products.iter(), |_| Ok(()))? + sseq.write_to_graph(SeqSeeBackend::new(out), 2, false, products.iter(), |_| { + Ok(()) + })? } _ => sseq.write_to_graph(SvgBackend::new(out), 2, false, products.iter(), |_| Ok(()))?, } From 472ba9c0c876481f810aa84dd095ae9c43c6ca3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 05:54:02 +0000 Subject: [PATCH 3/4] Address review: reuse Display impl and exhaustive format match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Derive node ids from `BidegreeGenerator`'s `Display` impl (`{:#}` → `(x,y,idx)`) instead of a bespoke `node_id` helper, keeping node creation and edge endpoints in sync. - Make the `chart` example's format dispatch exhaustive with an explicit `svg` arm and `unreachable!()`, since the format is already validated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CHkHh9W7nnkiM2Z6fhdHPw --- ext/crates/sseq/src/charting/seqsee.rs | 36 +++++++++----------------- ext/examples/chart.rs | 5 +++- 2 files changed, 16 insertions(+), 25 deletions(-) diff --git a/ext/crates/sseq/src/charting/seqsee.rs b/ext/crates/sseq/src/charting/seqsee.rs index 2e9551f75e..875230c77d 100644 --- a/ext/crates/sseq/src/charting/seqsee.rs +++ b/ext/crates/sseq/src/charting/seqsee.rs @@ -41,14 +41,6 @@ impl SeqSeeBackend { styles: BTreeSet::new(), } } - - /// The identifier used for the `idx`-th generator in bidegree `(x, y)`. - /// - /// This must agree between [`Self::node`] (which creates the nodes) and [`Self::structline`] - /// (which references them as edge endpoints). - fn node_id(x: i32, y: i32, idx: usize) -> String { - format!("{x},{y},{idx}") - } } /// Whether a style name denotes a differential, i.e. it is `d` followed by a page number. @@ -92,7 +84,9 @@ impl Backend for SeqSeeBackend { } for k in 0..n { - let id = Self::node_id(b.x(), b.y(), k); + // The `{:#}` (alternate) form of the `Display` impl is `(x,y,idx)`. Reusing it here and + // in `structline` keeps node ids and edge endpoints in sync. + let id = format!("{:#}", BidegreeGenerator::new(b, k)); self.nodes.insert( id, json!({ @@ -120,14 +114,8 @@ impl Backend for SeqSeeBackend { } let mut edge = Map::new(); - edge.insert( - "source".to_string(), - Value::String(Self::node_id(source.x(), source.y(), source.idx())), - ); - edge.insert( - "target".to_string(), - Value::String(Self::node_id(target.x(), target.y(), target.idx())), - ); + edge.insert("source".to_string(), Value::String(format!("{source:#}"))); + edge.insert("target".to_string(), Value::String(format!("{target:#}"))); if let Some(style) = style { self.styles.insert(style.to_string()); edge.insert("attributes".to_string(), json!([style])); @@ -214,15 +202,15 @@ mod tests { "attributes": [ "h0" ], - "source": "0,0,0", - "target": "0,1,0" + "source": "(0,0,0)", + "target": "(0,1,0)" }, { "attributes": [ "d2" ], - "source": "1,1,0", - "target": "0,1,0" + "source": "(1,1,0)", + "target": "(0,1,0)" } ], "header": { @@ -248,17 +236,17 @@ mod tests { } }, "nodes": { - "0,0,0": { + "(0,0,0)": { "position": 0, "x": 0, "y": 0 }, - "0,1,0": { + "(0,1,0)": { "position": 0, "x": 0, "y": 1 }, - "1,1,0": { + "(1,1,0)": { "position": 0, "x": 1, "y": 1 diff --git a/ext/examples/chart.rs b/ext/examples/chart.rs index a720c899a8..7b55bffcb4 100644 --- a/ext/examples/chart.rs +++ b/ext/examples/chart.rs @@ -27,6 +27,9 @@ fn main() -> anyhow::Result<()> { let out = std::io::stdout(); match format.as_str() { + "svg" => { + sseq.write_to_graph(SvgBackend::new(out), 2, false, products.iter(), |_| Ok(()))? + } "tikz" => { sseq.write_to_graph(TikzBackend::new(out), 2, false, products.iter(), |_| Ok(()))? } @@ -35,7 +38,7 @@ fn main() -> anyhow::Result<()> { Ok(()) })? } - _ => sseq.write_to_graph(SvgBackend::new(out), 2, false, products.iter(), |_| Ok(()))?, + _ => unreachable!(), } Ok(()) } From b6a86ebb4978dbbd2da1acf194c07cc60d1fa244 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 06:15:58 +0000 Subject: [PATCH 4/4] Make SeqSee output test independent of JSON key ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot test compared the pretty-printed JSON as a string, which baked in a particular object key order. serde_json's `preserve_order` feature gets enabled through feature unification in the full-workspace test build (but not in an isolated `-p sseq` build), so the emitted key order — and thus the test outcome — differed between local and CI runs. Compare the parsed `serde_json::Value`s instead, which is independent of object key ordering. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CHkHh9W7nnkiM2Z6fhdHPw --- ext/crates/sseq/src/charting/seqsee.rs | 84 ++++++++------------------ 1 file changed, 25 insertions(+), 59 deletions(-) diff --git a/ext/crates/sseq/src/charting/seqsee.rs b/ext/crates/sseq/src/charting/seqsee.rs index 875230c77d..6d530c296f 100644 --- a/ext/crates/sseq/src/charting/seqsee.rs +++ b/ext/crates/sseq/src/charting/seqsee.rs @@ -159,8 +159,6 @@ impl Drop for SeqSeeBackend { #[cfg(test)] mod tests { - use expect_test::expect; - use super::*; #[test] @@ -195,66 +193,34 @@ mod tests { backend.node(Bidegree::x_y(5, 5), 1).unwrap(); } - expect![[r#" - { - "edges": [ - { - "attributes": [ - "h0" - ], - "source": "(0,0,0)", - "target": "(0,1,0)" - }, - { - "attributes": [ - "d2" - ], - "source": "(1,1,0)", - "target": "(0,1,0)" - } - ], - "header": { - "aliases": { - "attributes": { - "d2": [ - { - "color": "blue" - } - ], - "h0": [] - } - }, + // Compare parsed values rather than the raw string so the assertion does not depend on + // JSON object key ordering, which varies with whether serde_json's `preserve_order` + // feature is enabled by feature unification in the surrounding build. + let produced: Value = serde_json::from_slice(&buf).unwrap(); + let expected = json!({ + "header": { "chart": { - "height": { - "max": 2, - "min": 0 - }, - "width": { - "max": 2, - "min": 0 - } - } - }, - "nodes": { - "(0,0,0)": { - "position": 0, - "x": 0, - "y": 0 + "width": { "min": 0, "max": 2 }, + "height": { "min": 0, "max": 2 }, }, - "(0,1,0)": { - "position": 0, - "x": 0, - "y": 1 + "aliases": { + "attributes": { + "h0": [], + "d2": [{ "color": "blue" }], + }, }, - "(1,1,0)": { - "position": 0, - "x": 1, - "y": 1 - } - } - } - "#]] - .assert_eq(std::str::from_utf8(&buf).unwrap()); + }, + "nodes": { + "(0,0,0)": { "x": 0, "y": 0, "position": 0 }, + "(0,1,0)": { "x": 0, "y": 1, "position": 0 }, + "(1,1,0)": { "x": 1, "y": 1, "position": 0 }, + }, + "edges": [ + { "source": "(0,0,0)", "target": "(0,1,0)", "attributes": ["h0"] }, + { "source": "(1,1,0)", "target": "(0,1,0)", "attributes": ["d2"] }, + ], + }); + assert_eq!(produced, expected); } #[test]