Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions vortex-web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,44 @@ A web UI for exploring Vortex data files, built with React, TypeScript, Tailwind
npm install
```

## Comparing compression output

Open the candidate `.vortex` file, select **Compare…** in the header, and choose the previous
version of the same file. The Compare view shows whole-file, data, and metadata byte deltas and
then aligns the layout and array-encoding trees to explain where those bytes changed.

The tree comparison is semantic rather than a diff of rendered labels. Layout siblings are
matched by field name, chunk row range, or named transparent/auxiliary role. Array children use
their encoding-provided child names, with their stable child position as a fallback for serialized
trees that do not carry names. This makes an inserted field an addition instead of making every
following field look modified.

To open a comparison directly, use the compare hash route with URL-encoded remote file URLs:

```text
https://explorer.example/#/compare?baseline=https%3A%2F%2Fdata.example%2Fbefore.vortex&candidate=https%3A%2F%2Fdata.example%2Fafter.vortex
```

An individual file can also be opened directly:

```text
https://explorer.example/#/file?url=https%3A%2F%2Fdata.example%2Foutput.vortex
```

URLs may be absolute HTTP(S) URLs or paths relative to the Explorer deployment. From the Compare
view, either file can be opened in the regular Details and Swimlane views or replaced with another
local file to recalculate the diff.

The hash route does not require server-side routing, so the Explorer remains a static application:
the browser fetches both files and opens them in the existing Web Workers. Each file host must
permit browser access with CORS. Local files still need to be selected manually because browsers
do not allow a page to read arbitrary local paths.

Use `compress-bench --ingest-jsonl <path>` for repeatable encode/decode timing and file-size
measurements. The Explorer comparison complements those aggregate measurements: it compares the
actual output files and attributes size changes to layout and encoding nodes. Compare files made
from identical logical input; the UI warns when row counts or schemas differ.

### Full App (requires Rust + wasm-pack)

```bash
Expand Down
143 changes: 143 additions & 0 deletions vortex-web/crate/src/array_tree_json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

use vortex::array::ArrayRef;
use vortex::array::display::ArrayTreeEvent;
use vortex::array::display::walk_array_tree;
use vortex::array::session::ArraySessionExt;
use vortex::session::VortexSession;

trait TraversalObserver {
fn enter(&self, _array: &ArrayRef) {}
fn exit(&self, _array: &ArrayRef) {}
}

impl TraversalObserver for () {}

/// Stream an array encoding tree directly to JSON in one depth-first traversal.
///
/// Each `ArrayTreeNode` is written before its children are visited. No recursive JSON model is
/// constructed, and the array's child iterator is consumed exactly once per node.
#[cfg(target_arch = "wasm32")]
pub(super) fn write_array_encoding_tree_json(
array: &ArrayRef,
session: &VortexSession,
) -> serde_json::Result<String> {
write_array_encoding_tree_json_with_observer(array, session, &())
}

fn write_array_encoding_tree_json_with_observer(
array: &ArrayRef,
session: &VortexSession,
observer: &impl TraversalObserver,
) -> serde_json::Result<String> {
let mut output = Vec::new();

walk_array_tree(array, |event| -> serde_json::Result<()> {
match event {
ArrayTreeEvent::Enter {
name,
node,
depth,
is_first,
..
} => {
let array = node.array();
observer.enter(array);
if depth > 0 && !is_first {
output.extend_from_slice(b",");
}

let name = if depth == 0 { "array" } else { name };
let encoding = array.encoding_id().to_string();
let dtype = array.dtype().to_string();
let buffer_names = array.buffer_names();
let buffer_handles = array.buffer_handles();
let buffer_lengths: Vec<usize> =
buffer_handles.iter().map(|buffer| buffer.len()).collect();
let metadata_bytes = session
.array_serialize(array)
.ok()
.flatten()
.map(|metadata| metadata.len())
.unwrap_or(0);

output.extend_from_slice(b"{\"name\":");
serde_json::to_writer(&mut output, name)?;
output.extend_from_slice(b",\"encoding\":");
serde_json::to_writer(&mut output, &encoding)?;
output.extend_from_slice(b",\"dtype\":");
serde_json::to_writer(&mut output, &dtype)?;
output.extend_from_slice(b",\"metadataBytes\":");
serde_json::to_writer(&mut output, &metadata_bytes)?;
output.extend_from_slice(b",\"numBuffers\":");
serde_json::to_writer(&mut output, &buffer_lengths.len())?;
output.extend_from_slice(b",\"bufferLengths\":");
serde_json::to_writer(&mut output, &buffer_lengths)?;
output.extend_from_slice(b",\"bufferNames\":");
serde_json::to_writer(&mut output, &buffer_names)?;
output.extend_from_slice(b",\"children\":[");
}
ArrayTreeEvent::Exit { node, .. } => {
observer.exit(node.array());
output.extend_from_slice(b"]}");
}
}
Ok(())
})?;

// Every byte written above is either fixed UTF-8 syntax or emitted by serde_json.
String::from_utf8(output).map_err(<serde_json::Error as serde::ser::Error>::custom)
}

#[cfg(test)]
mod tests {
use std::cell::Cell;

use serde_json::Value;
use vortex::VortexSessionDefault;
use vortex::array::IntoArray;
use vortex::array::arrays::StructArray;
use vortex::buffer::buffer;
use vortex::session::VortexSession;

use super::TraversalObserver;
use super::write_array_encoding_tree_json_with_observer;

#[derive(Default)]
struct CountingObserver {
enters: Cell<usize>,
exits: Cell<usize>,
}

impl TraversalObserver for CountingObserver {
fn enter(&self, _array: &vortex::array::ArrayRef) {
self.enters.set(self.enters.get() + 1);
}

fn exit(&self, _array: &vortex::array::ArrayRef) {
self.exits.set(self.exits.get() + 1);
}
}

#[test]
fn serializes_each_array_node_exactly_once() -> Result<(), Box<dyn std::error::Error>> {
let array = StructArray::from_fields(&[
("x", buffer![1_i32, 2].into_array()),
("y", buffer![3_i32, 4].into_array()),
])?
.into_array();
let session = VortexSession::default();
let observer = CountingObserver::default();

let json = write_array_encoding_tree_json_with_observer(&array, &session, &observer)?;
let tree: Value = serde_json::from_str(&json)?;

assert_eq!(observer.enters.get(), 3);
assert_eq!(observer.exits.get(), 3);
assert_eq!(tree["name"], "array");
assert_eq!(tree["children"][0]["name"], "x");
assert_eq!(tree["children"][1]["name"], "y");
Ok(())
}
}
12 changes: 10 additions & 2 deletions vortex-web/crate/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright the Vortex contributors

#![cfg(target_arch = "wasm32")]

//! WASM bindings for the Vortex web explorer.
//!
//! Built with `wasm-pack build --target web` and consumed by the vortex-web frontend.

#[cfg(any(target_arch = "wasm32", test))]
mod array_tree_json;

#[cfg(target_arch = "wasm32")]
use std::sync::LazyLock;

#[cfg(target_arch = "wasm32")]
use vortex::VortexSessionDefault;
#[cfg(target_arch = "wasm32")]
use vortex::io::runtime::wasm::WasmRuntime;
#[cfg(target_arch = "wasm32")]
use vortex::io::session::RuntimeSessionExt;
#[cfg(target_arch = "wasm32")]
use vortex::session::VortexSession;

#[cfg(target_arch = "wasm32")]
mod wasm;

#[cfg(target_arch = "wasm32")]
static SESSION: LazyLock<VortexSession> = LazyLock::new(|| {
let session = VortexSession::default().with_handle(WasmRuntime::handle());
session.allow_unknown();
Expand Down
Loading
Loading