diff --git a/tools/visualization_app/BUCK b/tools/visualization_app/BUCK index 83dc6cd86..b8607b048 100644 --- a/tools/visualization_app/BUCK +++ b/tools/visualization_app/BUCK @@ -9,7 +9,16 @@ BRANCH_NAME = read_package_value("directory_branching.branch_name") # Gets "dev buck_filegroup( name = "app_srcs", - srcs = glob(["**/*"]), + # Exclude installed deps / build output so they don't bloat the test target's + # input graph (node_modules is also gitignored). + srcs = glob( + ["**/*"], + exclude = [ + "node_modules/**", + "dist/**", + "dist-ssr/**", + ], + ), ) buck_genrule( @@ -36,6 +45,10 @@ buck_sh_binary( buck_genrule( name = "yarn-test", + # Declares the app sources as inputs so target determination schedules + # this test when any src/test file changes. The script reads them at + # runtime via `yarn test:run`, which Buck's graph cannot infer on its own. + srcs = [":app_srcs"], out = "yarn-test.sh", cmd = ( "echo -e {0} > $OUT && chmod +x $OUT".format( diff --git a/tools/visualization_app/src/graphVisualization/models/InteractiveChunkGraph.ts b/tools/visualization_app/src/graphVisualization/models/InteractiveChunkGraph.ts index 33220cecb..269818828 100644 --- a/tools/visualization_app/src/graphVisualization/models/InteractiveChunkGraph.ts +++ b/tools/visualization_app/src/graphVisualization/models/InteractiveChunkGraph.ts @@ -590,9 +590,16 @@ export class InteractiveChunkGraph { visited: Set, newlyVisibleNodes: RF_nodeId[], ) { + // Skip codecs already shown so a codec reachable via multiple in-graph + // paths (e.g. a diamond inside the graph) isn't traversed or pushed twice. + if (visited.has(codec)) { + return; + } + visited.add(codec); codec.isVisible = true; newlyVisibleNodes.push(codec.rfid); - if (codec.isCollapsed || visited.has(codec)) { + // A collapsed codec is shown but its children stay hidden. + if (codec.isCollapsed) { return; } this.codecDag!.getChildren(codec).forEach((childCodec) => { @@ -611,7 +618,14 @@ export class InteractiveChunkGraph { graph.parents = []; graph.children = []; graph.sortedChildren = []; - this.displayCodecsInGraph(graph.codecs[0], graph, new Set(), newlyVisibleNodes); + // Ensure that we account for all root nodes (if a node has multiple parents) and display correct codecs + const visited = new Set(); + graph.codecs.forEach((codec) => { + const isGraphEntry = !this.codecDag!.getParents(codec).some((parent) => parent.parentGraph === graph); + if (isGraphEntry) { + this.displayCodecsInGraph(codec, graph, visited, newlyVisibleNodes); + } + }); } // Collapsing this function graph else { diff --git a/tools/visualization_app/tests/codecDag.test.ts b/tools/visualization_app/tests/codecDag.test.ts new file mode 100644 index 000000000..8d286db72 --- /dev/null +++ b/tools/visualization_app/tests/codecDag.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, it, expect} from 'vitest'; +import {CodecDag} from '../src/graphVisualization/models/CodecDag'; +import {InternalCodecNode} from '../src/graphVisualization/models/InternalCodecNode'; +import {NodeType} from '../src/graphVisualization/models/types'; +import {Codec} from '../src/models/Codec'; +import {Stream} from '../src/models/Stream'; +import {LocalParamInfo} from '../src/models/LocalParamInfo'; +import {ZL_Type} from '../src/models/idTypes'; +import type {CodecID, StreamID, ChunkID, ZL_IDType} from '../src/models/idTypes'; +import type {RF_edgeId, RF_codecId} from '../src/graphVisualization/models/types'; + +/** + * Build a CodecDag from a declarative description. + * + * nodes: list of codec names, e.g. ['A','B','C'] + * edges: list of [sourceName, targetName] pairs, e.g. [['A','B'], ['B','C']] + * + * Returns the dag and a name->InternalCodecNode map for easy lookup in tests. + * Stream IDs and codec input/output lists are derived automatically, + * ensuring consistency. + */ +function buildCodecDag( + nodes: string[], + edges: [string, string][], +): {dag: CodecDag; codecs: Record} { + const nameToId = new Map(); + nodes.forEach((name, idx) => nameToId.set(name, idx)); + + // Collect input/output stream IDs per codec + const inputStreams = new Map(); + const outputStreams = new Map(); + nodes.forEach((_, idx) => { + inputStreams.set(idx, []); + outputStreams.set(idx, []); + }); + + const streams: Stream[] = edges.map(([srcName, dstName], streamId) => { + const srcId = nameToId.get(srcName); + const dstId = nameToId.get(dstName); + if (srcId === undefined || dstId === undefined) { + throw new Error(`Edge ${srcName}->${dstName} references unknown node`); + } + outputStreams.get(srcId)!.push(streamId); + inputStreams.get(dstId)!.push(streamId); + + const stream = new Stream( + streamId as StreamID, + 0 as ChunkID, + ZL_Type.ZL_Type_numeric, + 0, + 4, + 100, + 400, + 100, + 400, + undefined, + `C0-S${streamId}` as RF_edgeId, + ); + // CodecDag reads stream.targetCodec to build adjacency + stream.sourceCodec = srcId as CodecID; + stream.targetCodec = dstId as CodecID; + return stream; + }); + + const codecNodes: InternalCodecNode[] = []; + const codecsByName: Record = {}; + + nodes.forEach((name, id) => { + const codec = new Codec( + id as CodecID, + 0 as ChunkID, + name, + true, + 0 as ZL_IDType, + 1, + '', + new LocalParamInfo([], [], []), + (inputStreams.get(id) ?? []) as StreamID[], + (outputStreams.get(id) ?? []) as StreamID[], + ); + const node = new InternalCodecNode(`C0-T${id}` as RF_codecId, NodeType.Codec, codec, null); + codecNodes.push(node); + codecsByName[name] = node; + }); + + const dag = new CodecDag(codecNodes, streams); + return {dag, codecs: codecsByName}; +} + +// Shared topology fixtures – each built once and reused across method tests +const singleNode = buildCodecDag(['A'], []); +const linearChain = buildCodecDag( + ['A', 'B', 'C', 'D'], + [ + ['A', 'B'], + ['B', 'C'], + ['C', 'D'], + ], +); +const diamond = buildCodecDag( + ['A', 'B', 'C', 'D'], + [ + ['A', 'B'], + ['A', 'C'], + ['B', 'D'], + ['C', 'D'], + ], +); +const branchingTree = buildCodecDag( + ['A', 'B', 'C', 'D', 'E', 'F'], + [ + ['A', 'B'], + ['A', 'E'], + ['B', 'C'], + ['B', 'D'], + ['E', 'F'], + ], +); +const disconnected = buildCodecDag( + ['A', 'B', 'C', 'D'], + [ + ['A', 'B'], + ['C', 'D'], + ], +); + +describe('Test DAG ordering', () => { + it('single node returns single element', () => { + expect(singleNode.dag.dagOrder().map((c) => c.name)).toEqual(['A']); + }); + + it('linear chain orders A->B->C->D', () => { + expect(linearChain.dag.dagOrder().map((c) => c.name)).toEqual(['A', 'B', 'C', 'D']); + }); + + it('diamond places A first, D last, B/C middle', () => { + const order = diamond.dag.dagOrder().map((c) => c.name); + expect(order[0]).toBe('A'); + expect(order[3]).toBe('D'); + expect(new Set(order.slice(1, 3))).toEqual(new Set(['B', 'C'])); + }); + + it('branching tree respects parent-before-child', () => { + const order = branchingTree.dag.dagOrder().map((c) => c.name); + expect(order.indexOf('A')).toBeLessThan(order.indexOf('B')); + expect(order.indexOf('A')).toBeLessThan(order.indexOf('E')); + expect(order.indexOf('B')).toBeLessThan(order.indexOf('C')); + expect(order.indexOf('B')).toBeLessThan(order.indexOf('D')); + expect(order.indexOf('E')).toBeLessThan(order.indexOf('F')); + }); + + it('disconnected components include all nodes with intra-component ordering', () => { + const order = disconnected.dag.dagOrder().map((c) => c.name); + expect(order).toHaveLength(4); + expect(order.indexOf('A')).toBeLessThan(order.indexOf('B')); + expect(order.indexOf('C')).toBeLessThan(order.indexOf('D')); + }); +}); + +describe('Test reverse DAG order', () => { + it('single node returns single element', () => { + expect(singleNode.dag.reverseDagOrder().map((c) => c.name)).toEqual(['A']); + }); + + it('linear chain reverses correctly', () => { + expect(linearChain.dag.reverseDagOrder().map((c) => c.name)).toEqual(['D', 'C', 'B', 'A']); + }); +}); + +describe('Test DAG children', () => { + it('single node has no children', () => { + const A = singleNode.codecs.A; + expect(singleNode.dag.getChildren(A)).toEqual([]); + }); + + it('linear chain A has child B', () => { + const A = linearChain.codecs.A; + expect(linearChain.dag.getChildren(A).map((c) => c.name)).toEqual(['B']); + }); + + it('diamond root has children B and C', () => { + const A = diamond.codecs.A; + expect( + diamond.dag + .getChildren(A) + .map((c) => c.name) + .sort(), + ).toEqual(['B', 'C']); + }); + + it('branching tree children are correct', () => { + const {A, B} = branchingTree.codecs; + expect( + branchingTree.dag + .getChildren(A) + .map((c) => c.name) + .sort(), + ).toEqual(['B', 'E']); + expect( + branchingTree.dag + .getChildren(B) + .map((c) => c.name) + .sort(), + ).toEqual(['C', 'D']); + }); +}); + +describe('Test DAG parents', () => { + it('single node has no parents', () => { + const A = singleNode.codecs.A; + expect(singleNode.dag.getParents(A)).toEqual([]); + }); + + it('linear chain D has parent C', () => { + const D = linearChain.codecs.D; + expect(linearChain.dag.getParents(D).map((c) => c.name)).toEqual(['C']); + }); + + it('diamond merge has parents B and C', () => { + const D = diamond.codecs.D; + expect( + diamond.dag + .getParents(D) + .map((c) => c.name) + .sort(), + ).toEqual(['B', 'C']); + }); + + it('branching tree F has parent E', () => { + const F = branchingTree.codecs.F; + expect(branchingTree.dag.getParents(F).map((c) => c.name)).toEqual(['E']); + }); +}); diff --git a/tools/visualization_app/tests/createTestModels.ts b/tools/visualization_app/tests/createTestModels.ts deleted file mode 100644 index 280d3c39b..000000000 --- a/tools/visualization_app/tests/createTestModels.ts +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -import {InteractiveStreamdumpGraph} from '../src/graphVisualization/models/InteractiveStreamdumpGraph'; -import {Streamdump} from '../src/models/Streamdump'; -import {Stream} from '../src/models/Stream'; -import {Codec} from '../src/models/Codec'; -import {Graph} from '../src/models/Graph'; -import {LocalParamInfo} from '../src/models/LocalParamInfo'; -import { - ZL_Type, - ZL_GraphType, - OperationType, - type ChunkID, - type CodecID, - type GraphID, - type StreamID, - type ZL_IDType, -} from '../src/models/idTypes'; -import type {RF_edgeId} from '../src/graphVisualization/models/types'; -import type {StreamPreviewData} from '../src/interfaces/SerializedStream'; -import {Chunk} from '../src/models/Chunk'; - -// Match the values that decodeCbor's V0->V1 marshaller uses for synthetic -// streamdumps. traceVersion is 1 because we're constructing V1 directly. -const LIBRARY_VERSION = 100; -const FRAME_VERSION = -1; -const TRACE_VERSION = 1; - -// Test fixtures live in chunk 0 (single-chunk streamdumps). -const CHUNK_ID: ChunkID = 0 as ChunkID; - -// Helper function to create a Stream -export function createTestStream( - id: number, - type: ZL_Type = ZL_Type.ZL_Type_numeric, - eltWidth = 4, - numElts: number, - cSize: number, - share: number, - outputIdx = 0, - contentSize?: number, - streamPreview?: StreamPreviewData, - chunkId: ChunkID = CHUNK_ID, -): Stream { - return new Stream( - id as StreamID, - chunkId, - type, - outputIdx, - eltWidth, - numElts, - cSize, - share, - contentSize ?? cSize, - streamPreview, - `C${chunkId}-S${id}` as RF_edgeId, - ); -} - -// Helper function to create a Codec -export function createTestCodec( - id: number, - name: string, - cType = true, - headerSize = 1, - localParams: LocalParamInfo = new LocalParamInfo([], [], []), - inputStreams: number[] = [], - outputStreams: number[] = [], - cID = 0, - cFailureString = '', - chunkId: ChunkID = CHUNK_ID, -): Codec { - return new Codec( - id as CodecID, - chunkId, - name, - cType, - cID as ZL_IDType, - headerSize, - cFailureString, - localParams, - inputStreams as StreamID[], - outputStreams as StreamID[], - ); -} - -// Helper function to create a Graph -export function createTestGraph( - id: number, - type: ZL_GraphType = ZL_GraphType.ZL_GraphType_standard, - name: string, - localParams: LocalParamInfo = new LocalParamInfo([], [], []), - codecIDs: number[] = [], - gFailureString = '', - chunkId: ChunkID = CHUNK_ID, -): Graph { - return new Graph(id as GraphID, chunkId, type, name, gFailureString, localParams, codecIDs as CodecID[]); -} - -// Create a simple tree with a graph: A->B->C->D where B and C are in a graph -export function createSimpleTreeWithGraph(isDefaultCollapsed = false) { - const emptyLocalParams = new LocalParamInfo([], [], []); - - // Create streams (every codec has at most one output, so outputIdx defaults to 0) - const stream0 = createTestStream(0, ZL_Type.ZL_Type_numeric, 4, 100, 400, 33.3); - const stream1 = createTestStream(1, ZL_Type.ZL_Type_numeric, 4, 80, 320, 33.3); - const stream2 = createTestStream(2, ZL_Type.ZL_Type_numeric, 4, 60, 240, 33.3); - - // Create codecs - const codecA = createTestCodec(0, 'CodecA', true, 100, emptyLocalParams, [], [0]); - const codecB = createTestCodec(1, 'CodecB', true, 200, emptyLocalParams, [0], [1]); - const codecC = createTestCodec(2, 'CodecC', true, 300, emptyLocalParams, [1], [2]); - const codecD = createTestCodec(3, 'CodecD', true, 400, emptyLocalParams, [2], []); - - // Create graph - const graph = createTestGraph(0, ZL_GraphType.ZL_GraphType_standard, 'GraphBC', emptyLocalParams, [1, 2]); - - // Create streamdump (single chunk) - const streamdump = new Streamdump(LIBRARY_VERSION, FRAME_VERSION, TRACE_VERSION, OperationType.Compress, [ - new Chunk(CHUNK_ID, [stream0, stream1, stream2], [codecA, codecB, codecC, codecD], [graph]), - ]); - - return new InteractiveStreamdumpGraph(streamdump, isDefaultCollapsed); -} - -// Create a branching tree with two paths: A->B->C/D and A->E->F -export function createBranchingTreeWithGraph(isDefaultCollapsed = false) { - const emptyLocalParams = new LocalParamInfo([], [], []); - - // Create streams. outputIdx is the slot of the producing codec's output list: - // A produces [streamAB (idx 0), streamAE (idx 1)] - // B produces [streamBC (idx 0), streamBD (idx 1)] - // E produces [streamEF (idx 0)] - // Left branch (A->B->C and A->B->D) will have higher compression - const streamAB = createTestStream(0, ZL_Type.ZL_Type_numeric, 4, 100, 500, 25.0, 0); - const streamBC = createTestStream(1, ZL_Type.ZL_Type_numeric, 4, 80, 450, 22.5, 0); - const streamBD = createTestStream(2, ZL_Type.ZL_Type_numeric, 4, 70, 350, 17.5, 1); - - // Right branch (A->E->F) will have lower compression - const streamAE = createTestStream(3, ZL_Type.ZL_Type_numeric, 4, 90, 300, 15.0, 1); - const streamEF = createTestStream(4, ZL_Type.ZL_Type_numeric, 4, 60, 200, 10.0, 0); - - // Create codecs - const codecA = createTestCodec(0, 'Root', true, 100, emptyLocalParams, [], [0, 3]); - const codecB = createTestCodec(1, 'LeftBranch', true, 200, emptyLocalParams, [0], [1, 2]); - const codecC = createTestCodec(2, 'LeftLeaf1', true, 300, emptyLocalParams, [1], []); - const codecD = createTestCodec(3, 'LeftLeaf2', true, 400, emptyLocalParams, [2], []); - const codecE = createTestCodec(4, 'RightBranch', true, 500, emptyLocalParams, [3], [4]); - const codecF = createTestCodec(5, 'RightLeaf', true, 600, emptyLocalParams, [4], []); - - // Create graphs - const leftGraph = createTestGraph( - 0, - ZL_GraphType.ZL_GraphType_function, - 'LeftBranchGraph', - emptyLocalParams, - [1, 2, 3], - ); - const rightGraph = createTestGraph( - 1, - ZL_GraphType.ZL_GraphType_function, - 'RightBranchGraph', - emptyLocalParams, - [4, 5], - ); - - // Create streamdump (single chunk) - const streamdump = new Streamdump(LIBRARY_VERSION, FRAME_VERSION, TRACE_VERSION, OperationType.Compress, [ - new Chunk( - CHUNK_ID, - [streamAB, streamBC, streamBD, streamAE, streamEF], - [codecA, codecB, codecC, codecD, codecE, codecF], - [leftGraph, rightGraph], - ), - ]); - - return new InteractiveStreamdumpGraph(streamdump, isDefaultCollapsed); -} diff --git a/tools/visualization_app/tests/interactiveChunkGraph.test.ts b/tools/visualization_app/tests/interactiveChunkGraph.test.ts new file mode 100644 index 000000000..e9669b266 --- /dev/null +++ b/tools/visualization_app/tests/interactiveChunkGraph.test.ts @@ -0,0 +1,19 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {describe, it, expect} from 'vitest'; +import {createSingleNodeGraph} from './utils/createTestModels'; +import type {InteractiveChunkGraph} from '../src/graphVisualization/models/InteractiveChunkGraph'; + +describe('Test InteractiveChunkGraph internal APIs', () => { + it('contains and findCodecByName work for present and missing', () => { + const streamdumpGraph = createSingleNodeGraph(); + const chunkGraph = (streamdumpGraph as any).chunkGraphs[0] as InteractiveChunkGraph; + + const found = chunkGraph.findCodecByName('SingleNode'); + expect(found?.name).toBe('SingleNode'); + expect(chunkGraph.contains(found!)).toBe(true); + + const notFound = chunkGraph.findCodecByName('Missing'); + expect(notFound).toBeNull(); + }); +}); diff --git a/tools/visualization_app/tests/interactiveStreamdumpGraph.test.ts b/tools/visualization_app/tests/interactiveStreamdumpGraph.test.ts index ae2b2cc92..be35fbeea 100644 --- a/tools/visualization_app/tests/interactiveStreamdumpGraph.test.ts +++ b/tools/visualization_app/tests/interactiveStreamdumpGraph.test.ts @@ -3,7 +3,14 @@ // /data/users/agandevia/fbsource/fbcode/data_compression/experimental/sd3/streamdump/visualization-app/tests/sample.test.ts import {describe, it, expect} from 'vitest'; -import {createSimpleTreeWithGraph, createBranchingTreeWithGraph} from './createTestModels'; +import { + createSimpleTreeWithGraph, + createBranchingTreeWithGraph, + createSingleNodeGraph, + createDiamondGraph, + createCollapsableDiamondGraph, + createMultiChunkGraph, +} from './utils/createTestModels'; import {InternalGraphNode} from '../src/graphVisualization/models/InternalGraphNode'; import {InternalCodecNode} from '../src/graphVisualization/models/InternalCodecNode'; import type {InteractiveStreamdumpGraph} from '../src/graphVisualization/models/InteractiveStreamdumpGraph'; @@ -40,7 +47,7 @@ describe('Test interactive streamdump graph creation', () => { // Test collapsing the graph interactiveGraph.toggleGraphCollapse(graphs[0]); - const {dagOrderedNodes: collapsedNodes, edges: _} = interactiveGraph.getVisibleStreamdumpGraph(); + const {dagOrderedNodes: collapsedNodes} = interactiveGraph.getVisibleStreamdumpGraph(); const collapsedGraphs = collapsedNodes.filter( (node): node is InternalGraphNode => node instanceof InternalGraphNode, ); @@ -294,3 +301,86 @@ describe('Test the largest compression path', () => { expect(codecByName('RightLeaf').inLargestCompressionPath).toBe(false); // F }); }); + +describe('Test multi-chunk segmenter merging', () => { + it('should merge chunk 0 segmenter with chunk 1', () => { + const interactiveGraph = createMultiChunkGraph(); + // Initially only chunk 0 visible: segmenter -> CodecA0 + let {nodes, edges, codecs} = getInteractiveGraphDetails(interactiveGraph); + expect(nodes.length).toBe(2); + expect(edges.length).toBe(1); + + // Set visible chunk to 1: segmenter merges into chunk 1, zl.#start omitted + interactiveGraph.setVisibleChunk(1); + ({nodes, edges, codecs} = getInteractiveGraphDetails(interactiveGraph)); + expect(nodes.length).toBe(4); // segmenter, CodecA0, CodecB1, CodecC1 + expect(codecs.map((c) => c.name)).not.toContain('zl.#start'); // omitted + expect(edges.length).toBe(3); + // segmenter (C0-T0) replaces zl.#start as CodecB1's (C1-T1) parent + expect(edges.some((e) => e.source.rfid === 'C0-T0' && e.target.rfid === 'C1-T1')).toBe(true); + + // Set back to null (only chunk 0) + interactiveGraph.setVisibleChunk(null); + ({nodes, edges} = getInteractiveGraphDetails(interactiveGraph)); + expect(nodes.length).toBe(2); + expect(edges.length).toBe(1); + }); +}); + +describe('Diamond topology tests', () => { + it('diamond graph has correct topological order and child ordering', () => { + const interactiveGraph = createDiamondGraph(); + const {codecs, edges, nodes} = getInteractiveGraphDetails(interactiveGraph); + + expect(nodes.length).toBe(4); + expect(edges.length).toBe(4); + + // Topological order: Root first, Merge last, Left/Right in between + const names = codecs.map((c) => c.name); + expect(names[0]).toBe('Root'); + expect(names[3]).toBe('Merge'); + expect(new Set(names.slice(1, 3))).toEqual(new Set(['Left', 'Right'])); + + interactiveGraph.buildAllNavlinks(); + const root = codecs.find((c) => c.name === 'Root')!; + + // sortedChildren orders by share: Left (60%) before Right (40%) + const sortedChildNames = root.sortedChildren.map((rfid) => codecs.find((c) => c.rfid === rfid)?.name); + expect(sortedChildNames).toEqual(['Left', 'Right']); + }); + + it('diamond with graph collapses to proxy edges', () => { + const interactiveGraph = createCollapsableDiamondGraph(); + const {graphs} = getInteractiveGraphDetails(interactiveGraph); + expect(graphs.length).toBe(1); + // Graph contains Left (C0-T1) and Right (C0-T2), rfid = C0-G0 + expect(graphs[0].rfid).toBe('C0-G0'); + + interactiveGraph.toggleGraphCollapse(graphs[0]); + let {nodes, edges, graphs: collapsedGraphs} = getInteractiveGraphDetails(interactiveGraph); + expect(collapsedGraphs[0].isCollapsed).toBe(true); + expect(nodes.length).toBe(3); // Root, collapsed graph, Merge + expect(edges.length).toBe(2); + + // Verify proxy edges replace internal codecs: Root -> graph -> Merge + expect(edges.some((e) => e.source.rfid === 'C0-T0' && e.target.rfid === 'C0-G0')).toBe(true); // Root -> graph + expect(edges.some((e) => e.source.rfid === 'C0-G0' && e.target.rfid === 'C0-T3')).toBe(true); // graph -> Merge + + // Expand back + interactiveGraph.toggleGraphCollapse(collapsedGraphs[0]); + ({nodes, edges, graphs: collapsedGraphs} = getInteractiveGraphDetails(interactiveGraph)); + expect(collapsedGraphs[0].isCollapsed).toBe(false); + expect(nodes.length).toBe(5); // Root, Left, Right, Merge, graph node + expect(edges.length).toBe(4); + }); +}); + +describe('Single node edge case', () => { + it('single node graph renders with no edges', () => { + const interactiveGraph = createSingleNodeGraph(); + const {nodes, edges, codecs} = getInteractiveGraphDetails(interactiveGraph); + expect(nodes.length).toBe(1); + expect(edges.length).toBe(0); + expect(codecs[0].name).toBe('SingleNode'); + }); +}); diff --git a/tools/visualization_app/tests/utils/createTestModels.ts b/tools/visualization_app/tests/utils/createTestModels.ts new file mode 100644 index 000000000..cf7ebffd4 --- /dev/null +++ b/tools/visualization_app/tests/utils/createTestModels.ts @@ -0,0 +1,290 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +import {InteractiveStreamdumpGraph} from '../../src/graphVisualization/models/InteractiveStreamdumpGraph'; +import {Streamdump} from '../../src/models/Streamdump'; +import {Stream} from '../../src/models/Stream'; +import {Codec} from '../../src/models/Codec'; +import {Graph} from '../../src/models/Graph'; +import {LocalParamInfo} from '../../src/models/LocalParamInfo'; +import { + ZL_Type, + ZL_GraphType, + OperationType, + type ChunkID, + type CodecID, + type GraphID, + type StreamID, + type ZL_IDType, +} from '../../src/models/idTypes'; +import type {RF_edgeId} from '../../src/graphVisualization/models/types'; +import type {StreamPreviewData} from '../../src/interfaces/SerializedStream'; +import {Chunk} from '../../src/models/Chunk'; + +// Match the values that decodeCbor's V0->V1 marshaller uses for synthetic +// streamdumps. traceVersion is 1 because we're constructing V1 directly. +const LIBRARY_VERSION = 100; +const FRAME_VERSION = -1; +const TRACE_VERSION = 1; + +// Test fixtures live in chunk 0 (single-chunk streamdumps). +const CHUNK_ID: ChunkID = 0 as ChunkID; + +const EMPTY_LOCAL_PARAMS = new LocalParamInfo([], [], []); + +// Helper function to create a Stream +export function createTestStream( + id: number, + type: ZL_Type = ZL_Type.ZL_Type_numeric, + eltWidth = 4, + numElts: number, + cSize: number, + share: number, + outputIdx = 0, + contentSize?: number, + streamPreview?: StreamPreviewData, + chunkId: ChunkID = CHUNK_ID, +): Stream { + return new Stream( + id as StreamID, + chunkId, + type, + outputIdx, + eltWidth, + numElts, + cSize, + share, + contentSize ?? cSize, + streamPreview, + `C${chunkId}-S${id}` as RF_edgeId, + ); +} + +// Helper function to create a Codec +export function createTestCodec( + id: number, + name: string, + cType = true, + headerSize = 1, + localParams: LocalParamInfo = EMPTY_LOCAL_PARAMS, + inputStreams: number[] = [], + outputStreams: number[] = [], + cID = 0, + cFailureString = '', + chunkId: ChunkID = CHUNK_ID, +): Codec { + return new Codec( + id as CodecID, + chunkId, + name, + cType, + cID as ZL_IDType, + headerSize, + cFailureString, + localParams, + inputStreams as StreamID[], + outputStreams as StreamID[], + ); +} + +// Helper function to create a Graph +export function createTestGraph( + id: number, + type: ZL_GraphType = ZL_GraphType.ZL_GraphType_standard, + name: string, + localParams: LocalParamInfo = EMPTY_LOCAL_PARAMS, + codecIDs: number[] = [], + gFailureString = '', + chunkId: ChunkID = CHUNK_ID, +): Graph { + return new Graph(id as GraphID, chunkId, type, name, gFailureString, localParams, codecIDs as CodecID[]); +} + +// Create a simple tree with a graph: A->B->C->D where B and C are in a graph +export function createSimpleTreeWithGraph(isDefaultCollapsed = false) { + // Create streams (every codec has at most one output, so outputIdx defaults to 0) + const stream0 = createTestStream(0, ZL_Type.ZL_Type_numeric, 4, 100, 400, 33.3); + const stream1 = createTestStream(1, ZL_Type.ZL_Type_numeric, 4, 80, 320, 33.3); + const stream2 = createTestStream(2, ZL_Type.ZL_Type_numeric, 4, 60, 240, 33.3); + + // Create codecs + const codecA = createTestCodec(0, 'CodecA', true, 100, EMPTY_LOCAL_PARAMS, [], [0]); + const codecB = createTestCodec(1, 'CodecB', true, 200, EMPTY_LOCAL_PARAMS, [0], [1]); + const codecC = createTestCodec(2, 'CodecC', true, 300, EMPTY_LOCAL_PARAMS, [1], [2]); + const codecD = createTestCodec(3, 'CodecD', true, 400, EMPTY_LOCAL_PARAMS, [2], []); + + // Create graph + const graph = createTestGraph(0, ZL_GraphType.ZL_GraphType_standard, 'GraphBC', EMPTY_LOCAL_PARAMS, [1, 2]); + + // Create streamdump (single chunk) + const streamdump = new Streamdump(LIBRARY_VERSION, FRAME_VERSION, TRACE_VERSION, OperationType.Compress, [ + new Chunk(CHUNK_ID, [stream0, stream1, stream2], [codecA, codecB, codecC, codecD], [graph]), + ]); + + return new InteractiveStreamdumpGraph(streamdump, isDefaultCollapsed); +} + +// Create a single node graph with no edges +export function createSingleNodeGraph(isDefaultCollapsed = false) { + const codecA = createTestCodec(0, 'SingleNode'); + + const streamdump = new Streamdump(LIBRARY_VERSION, FRAME_VERSION, TRACE_VERSION, OperationType.Compress, [ + new Chunk(CHUNK_ID, [], [codecA], []), + ]); + + return new InteractiveStreamdumpGraph(streamdump, isDefaultCollapsed); +} + +// Shared diamond topology: Root -> Left/Right -> Merge +function buildDiamondFixture() { + // Streams with different shares to test sorting: Left path larger share + const streamAB = createTestStream(0, ZL_Type.ZL_Type_numeric, 4, 100, 400, 60.0, 0); + const streamAC = createTestStream(1, ZL_Type.ZL_Type_numeric, 4, 100, 400, 40.0, 1); + const streamBD = createTestStream(2, ZL_Type.ZL_Type_numeric, 4, 80, 320, 60.0, 0); + const streamCD = createTestStream(3, ZL_Type.ZL_Type_numeric, 4, 80, 320, 40.0, 0); + + // Codecs: Root has 2 outputs, Left/Right each have 1, Merge has 2 inputs + const codecA = createTestCodec(0, 'Root', true, 100, EMPTY_LOCAL_PARAMS, [], [0, 1]); + const codecB = createTestCodec(1, 'Left', true, 200, EMPTY_LOCAL_PARAMS, [0], [2]); + const codecC = createTestCodec(2, 'Right', true, 300, EMPTY_LOCAL_PARAMS, [1], [3]); + const codecD = createTestCodec(3, 'Merge', true, 400, EMPTY_LOCAL_PARAMS, [2, 3], []); + + return { + streams: [streamAB, streamAC, streamBD, streamCD], + codecs: [codecA, codecB, codecC, codecD], + }; +} + +// Create a diamond shaped graph +export function createDiamondGraph(isDefaultCollapsed = false) { + const {streams, codecs} = buildDiamondFixture(); + + const streamdump = new Streamdump(LIBRARY_VERSION, FRAME_VERSION, TRACE_VERSION, OperationType.Compress, [ + new Chunk(CHUNK_ID, streams, codecs, []), + ]); + + return new InteractiveStreamdumpGraph(streamdump, isDefaultCollapsed); +} + +// Create diamond shaped graph that is collapsable (specifically can collapse output of root node) +export function createCollapsableDiamondGraph(isDefaultCollapsed = false) { + const {streams, codecs} = buildDiamondFixture(); + + const graph = createTestGraph(0, ZL_GraphType.ZL_GraphType_standard, 'DiamondGraph', EMPTY_LOCAL_PARAMS, [1, 2]); + + const streamdump = new Streamdump(LIBRARY_VERSION, FRAME_VERSION, TRACE_VERSION, OperationType.Compress, [ + new Chunk(CHUNK_ID, streams, codecs, [graph]), + ]); + + return new InteractiveStreamdumpGraph(streamdump, isDefaultCollapsed); +} + +// Create multi-chunk streamdump with segmenter +export function createMultiChunkGraph(isDefaultCollapsed = false) { + // Chunk 0: segmenter -> A + const stream0_0 = createTestStream( + 0, + ZL_Type.ZL_Type_numeric, + 4, + 100, + 400, + 100.0, + 0, + undefined, + undefined, + 0 as ChunkID, + ); + const segmenter = createTestCodec(0, 'segmenter', true, 50, EMPTY_LOCAL_PARAMS, [], [0], 0, '', 0 as ChunkID); + const codecA0 = createTestCodec(1, 'CodecA0', true, 100, EMPTY_LOCAL_PARAMS, [0], [], 0, '', 0 as ChunkID); + + const chunk0 = new Chunk(0 as ChunkID, [stream0_0], [segmenter, codecA0], []); + + // Chunk 1: zl.#start -> B -> C + const stream1_0 = createTestStream( + 0, + ZL_Type.ZL_Type_numeric, + 4, + 100, + 400, + 100.0, + 0, + undefined, + undefined, + 1 as ChunkID, + ); + const stream1_1 = createTestStream( + 1, + ZL_Type.ZL_Type_numeric, + 4, + 80, + 320, + 100.0, + 0, + undefined, + undefined, + 1 as ChunkID, + ); + const startCodec = createTestCodec(0, 'zl.#start', true, 10, EMPTY_LOCAL_PARAMS, [], [0], 0, '', 1 as ChunkID); + const codecB1 = createTestCodec(1, 'CodecB1', true, 200, EMPTY_LOCAL_PARAMS, [0], [1], 0, '', 1 as ChunkID); + const codecC1 = createTestCodec(2, 'CodecC1', true, 300, EMPTY_LOCAL_PARAMS, [1], [], 0, '', 1 as ChunkID); + + const chunk1 = new Chunk(1 as ChunkID, [stream1_0, stream1_1], [startCodec, codecB1, codecC1], []); + + const streamdump = new Streamdump(LIBRARY_VERSION, FRAME_VERSION, TRACE_VERSION, OperationType.Compress, [ + chunk0, + chunk1, + ]); + + return new InteractiveStreamdumpGraph(streamdump, isDefaultCollapsed); +} + +// Create a branching tree with two paths: A->B->C/D and A->E->F +export function createBranchingTreeWithGraph(isDefaultCollapsed = false) { + // Create streams. outputIdx is the slot of the producing codec's output list: + // A produces [streamAB (idx 0), streamAE (idx 1)] + // B produces [streamBC (idx 0), streamBD (idx 1)] + // E produces [streamEF (idx 0)] + // Left branch (A->B->C and A->B->D) will have higher compression + const streamAB = createTestStream(0, ZL_Type.ZL_Type_numeric, 4, 100, 500, 25.0, 0); + const streamBC = createTestStream(1, ZL_Type.ZL_Type_numeric, 4, 80, 450, 22.5, 0); + const streamBD = createTestStream(2, ZL_Type.ZL_Type_numeric, 4, 70, 350, 17.5, 1); + + // Right branch (A->E->F) will have lower compression + const streamAE = createTestStream(3, ZL_Type.ZL_Type_numeric, 4, 90, 300, 15.0, 1); + const streamEF = createTestStream(4, ZL_Type.ZL_Type_numeric, 4, 60, 200, 10.0, 0); + + // Create codecs + const codecA = createTestCodec(0, 'Root', true, 100, EMPTY_LOCAL_PARAMS, [], [0, 3]); + const codecB = createTestCodec(1, 'LeftBranch', true, 200, EMPTY_LOCAL_PARAMS, [0], [1, 2]); + const codecC = createTestCodec(2, 'LeftLeaf1', true, 300, EMPTY_LOCAL_PARAMS, [1], []); + const codecD = createTestCodec(3, 'LeftLeaf2', true, 400, EMPTY_LOCAL_PARAMS, [2], []); + const codecE = createTestCodec(4, 'RightBranch', true, 500, EMPTY_LOCAL_PARAMS, [3], [4]); + const codecF = createTestCodec(5, 'RightLeaf', true, 600, EMPTY_LOCAL_PARAMS, [4], []); + + // Create graphs + const leftGraph = createTestGraph( + 0, + ZL_GraphType.ZL_GraphType_function, + 'LeftBranchGraph', + EMPTY_LOCAL_PARAMS, + [1, 2, 3], + ); + const rightGraph = createTestGraph( + 1, + ZL_GraphType.ZL_GraphType_function, + 'RightBranchGraph', + EMPTY_LOCAL_PARAMS, + [4, 5], + ); + + // Create streamdump (single chunk) + const streamdump = new Streamdump(LIBRARY_VERSION, FRAME_VERSION, TRACE_VERSION, OperationType.Compress, [ + new Chunk( + CHUNK_ID, + [streamAB, streamBC, streamBD, streamAE, streamEF], + [codecA, codecB, codecC, codecD, codecE, codecF], + [leftGraph, rightGraph], + ), + ]); + + return new InteractiveStreamdumpGraph(streamdump, isDefaultCollapsed); +}