Architecture: Trace System & Core Modularization
Week at a Glance
- Built the trace export/import system — portable execution recordings in JSON and CBOR
- Added schema versioning (v1) for forward-compatible trace files
- Implemented graph hashing (FNV-1a) for replay verification
- Created streaming trace I/O for large traces that don’t fit in memory
- Modularized the core crate — extracted components/, executor/, commands/, validation/
- Created the UI crate with proper dependency separation (UI depends on core, never the reverse)
Key Decisions
Two major architectural decisions this week, both about boundaries.
Context: Traces need to be shareable between different versions of the tool. A trace recorded today must be loadable next month, even if the internal representation changes.
Decision: Versioned trace schema with explicit
TRACE_SCHEMA_VERSIONand embedded graph hash.Rationale: The schema version tells the loader which deserialization path to use. The graph hash verifies that the trace was recorded against a compatible graph structure — replaying a trace on a modified graph would produce nonsensical results.
Consequences: Adding a new field to traces requires bumping the schema version and writing a migration path. This is intentional friction — trace format changes should be deliberate.
Context: The core crate had grown to ~3,400 lines in a flat structure. The executor alone was 2,200 lines. Modules for graph manipulation, validation, execution, and commands were all siblings in
src/.Decision: Extract into a multi-module hierarchy:
components/,executor/,commands/,validation/. Simultaneously, create a separateuicrate.Rationale: The UI should never leak into core. Core should be usable as a library for headless execution, testing, and AI-driven graph construction. The crate boundary enforces this at compile time.
Consequences: The workspace now has
crates/coreandcrates/ui. Core has zero knowledge of rendering or windowing. UI depends on core but adds wgpu, winit, and glam.
What We Built
Trace Export/Import
A trace captures a range of execution history as a portable recording:
pub struct Trace {
pub schema_version: u32,
pub graph_hash: u64,
pub timeline_id: TimelineId,
pub start_tick: u64,
pub end_tick: u64,
pub snapshots: Vec<TickSnapshot>,
pub inputs: BTreeMap<u64, InputFrame>,
}
The graph_hash is computed using FNV-1a over the graph’s structural identity — node IDs, port configurations, edge connections. If the graph changes (node added, edge rewired), the hash changes and the trace is flagged as incompatible.
Traces serialize to both JSON (human-readable, debugging) and CBOR (compact, production). A 10,000-tick trace with 100 signals compresses to ~500KB in CBOR versus ~5MB in JSON.
Streaming Trace I/O
For traces too large to hold in memory, we added streaming export and import:
// Streaming export — writes snapshots one at a time
pub fn export_trace_stream<W: Write>(
&self,
writer: &mut W,
from: u64,
to: u64,
) -> Result<(), TraceError> {
write_header(writer, self.schema_version, self.graph_hash)?;
for tick in from..=to {
let snapshot = self.snapshot_at(tick)?;
write_snapshot(writer, &snapshot)?;
}
write_footer(writer)?;
Ok(())
}
// Streaming import — replays one snapshot at a time
pub fn import_trace_stream<R: Read>(
&mut self,
reader: &mut R,
) -> Result<(), ReplayError> {
let header = read_header(reader)?;
verify_compatibility(header.graph_hash, self.graph_hash())?;
while let Some(snapshot) = read_snapshot(reader)? {
self.apply_snapshot(snapshot)?;
}
Ok(())
}
Graph Hashing
The graph hash function walks the entire graph structure deterministically:
pub fn graph_hash(graph: &Graph) -> u64 {
let mut hasher = FnvHasher::default();
// Hash nodes in ID order (deterministic)
for node in graph.nodes.iter().sorted_by_key(|n| n.id) {
hasher.write_u32(node.id.0);
// Hash ports, kind, etc.
}
// Hash edges in sorted order
for edge in graph.edges.iter().sorted() {
hasher.write_u32(edge.from_node.0);
// ... all edge fields
}
hasher.finish()
}
Sorting by ID ensures the hash is independent of insertion order — two structurally identical graphs always produce the same hash.
What We Removed
The flat module structure in src/ was replaced with a proper hierarchy. Files like src/nodes.rs, src/edge.rs, src/port.rs moved into src/components/. The 2,200-line executor was split into executor/checkpoint.rs, executor/snapshot.rs, executor/timeline.rs, executor/trace.rs, and executor/trace_stream.rs, with the main execution logic remaining in the parent module.
The extraction didn’t change any public APIs — all types are re-exported through lib.rs. The change is purely organizational, but it makes the codebase navigable. Finding the checkpoint implementation now means going to executor/checkpoint.rs instead of scrolling through 2,200 lines.
Developer Experience
The workspace structure enables independent development on core and UI:
# Root Cargo.toml
[workspace]
members = ["crates/core", "crates/ui"]
# ...
# crates/ui/Cargo.toml
[dependencies]
xtranodly-core = { path = "../core" }
wgpu = "27.0.1"
winit = "0.30.12"
glam = "0.30.9"
# ...
cargo test -p xtranodly-core runs core tests without compiling the UI. cargo build -p xtranodly-ui builds the full application. This separation cuts iteration time significantly when working on execution logic — no GPU shader compilation, no windowing setup.
Validation
Trace round-trip test: export a 1,000-tick trace to JSON, import into a fresh executor with the same graph, verify every snapshot matches. Same test with CBOR format.
Streaming test: export a 10,000-tick trace via streaming, import via streaming into a separate executor, verify final state matches.
Graph hash test: compute hash, add a node, verify hash changes. Remove the node, verify hash returns to original. Reorder nodes in the Vec, verify hash stays the same (order-independent due to sorting).
Schema compatibility test: attempt to import a trace with a different schema version, verify ReplayError::IncompatibleSchema. Attempt import with different graph hash, verify ReplayError::GraphMismatch.
What’s Next
- Begin the UI rendering pipeline — wgpu setup, camera system, node rendering
- Implement graph I/O — load/save graph and layout from disk
- Build the node view system for visual positioning
- Design context navigation for drilling into subgraphs