diff --git a/README.md b/README.md index 5139a9f..8cce5bc 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ Starting from a `demo-db.duckdb` with `nodes_user`, `nodes_city`, `edges_follows ```bash uv run icebug-format \ - --directed \ --source-db demo-db.duckdb \ --schema demo-db/schema.cypher ``` @@ -83,11 +82,11 @@ graph: IcebugMemGraph = IcebugMemGraph.from_arrow_tables( to_node_arrow_table=cities, # pa.Table, first column is the primary key ) -# Directed or undirected homogeneous graph (same node type on both ends) +# Directed graph, or homogeneous graph with reverse edges added graph: IcebugMemGraph = IcebugMemGraph.from_arrow_tables( from_node_arrow_table=users, # pa.Table, first column is the primary key rel_arrow_table=follows, # pa.Table with 'source' and 'target' columns - undirected=True, # undirected=True for undirected (to_node_arrow_table must be omitted) + add_reverse_edges=True, # to_node_arrow_table must be omitted ) # Node tables are passed through unchanged @@ -108,16 +107,16 @@ The `rel_arrow_table` source and target columns are resolved by name in priority Any remaining columns are preserved as edge properties in `graph.indices`. -Set `undirected=True` to automatically add reverse edges (undirected graph). For undirected graphs, `to_node_arrow_table` must be omitted; the same node table is used for both sides of every edge. +Use `--add-reverse-edges` in the CLI, or `add_reverse_edges=True` in the Python API, to emit a symmetric adjacency by adding reverse edges. For reverse-edge expansion, `to_node_arrow_table` must be omitted; the same node table is used for both sides of every edge. ## Caveats - icebug-format will always output a directed graph -- If you want an undirected graph to be converted, pass undirected=True to the CLI or Python API, and the reverse edges will be added automatically. But do note that undirected graphs are supported for rel tables with same node type on both ends only +- If an algorithm needs symmetric adjacency, pass `--add-reverse-edges` to the CLI or `add_reverse_edges=True` to the Python API. Reverse edges will be added automatically. Reverse-edge expansion is supported only for rel tables with the same node type on both ends. +- Reverse-edge expansion is all or nothing for a conversion. If your graph mixes edge types that should be symmetric, such as `friends`, with edge types that should stay directed, such as `follows`, run separate conversions or add reverse edges before calling icebug-format; `--add-reverse-edges` cannot be applied selectively per edge type. --- ## Further reading [Blog post: Graph Archiving with Apache GraphAR](https://adsharma.github.io/graph-archiving/) - diff --git a/doc/spec.md b/doc/spec.md index 4935cb4..2506657 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -30,7 +30,7 @@ icebug-format \ [--edge-table EDGE_TABLE] \ [--schema SCHEMA_CYPHER] \ [--storage STORAGE_PATH] \ - [--directed] \ + [--add-reverse-edges] \ [--test] \ [--limit N] \ [--memory-limit LIMIT] @@ -210,23 +210,25 @@ For each selected edge table `ET`: Only edges whose endpoints both exist in the selected node mappings are emitted, because the conversion uses inner-join semantics. -### Directed and Undirected Modes +### Direction and Reverse Edges -With `--directed`, each non-self-loop input edge produces one output edge: +By default, input edges are treated as directed. Each input edge produces one +output edge: ```text source -> target ``` -Without `--directed`, the graph is treated as undirected. Each non-self-loop -input edge produces two output edges: +With `--add-reverse-edges`, each non-self-loop input edge also emits a reverse +edge, producing symmetric adjacency for algorithms that need it. Each self-loop +appears once: ```text source -> target target -> source ``` -Edge properties are copied onto both directions in undirected mode. +Edge properties are copied onto both directions when reverse edges are added. ### Test Limit @@ -239,8 +241,8 @@ When multiple edge tables are selected, the per-table limit is: floor(limit / number_of_edge_tables) ``` -The limit is applied before undirected edge duplication. In undirected mode, a -per-table limit of `L` can therefore emit up to `2L` rows for that edge type. +The limit is applied before reverse-edge expansion. With `--add-reverse-edges`, +a per-table limit of `L` can therefore emit up to `2L` rows for that edge type. The implementation does not define a deterministic ordering before applying the limit, so callers should treat test-mode sampling as backend-dependent unless @@ -366,15 +368,15 @@ Columns: ```text n_nodes n_edges -directed +add_reverse_edges ``` `n_nodes` is the sum of selected node table row counts. -`n_edges` is the sum of emitted rows across all generated indices tables. In -undirected mode this includes duplicated reverse edges. +`n_edges` is the sum of emitted rows across all generated indices tables. With +`--add-reverse-edges`, this includes generated reverse edges. -`directed` records whether `--directed` was supplied. +`add_reverse_edges` records whether `--add-reverse-edges` was supplied. ### Parquet Directory @@ -491,7 +493,7 @@ A non-DuckDB backend must provide equivalent behavior for: - assigning zero-based dense IDs with deterministic ordering - joining edge tables to source and destination mapping tables - filtering self-loops -- duplicating edges for undirected mode +- adding reverse edges for symmetric adjacency - preserving edge property columns and values - grouping emitted edges by `csr_source` to compute degrees - creating cumulative CSR offsets for all source node IDs from `0` to `N - 1`, diff --git a/icebug_format/cli.py b/icebug_format/cli.py index 12ac58d..e2915f0 100644 --- a/icebug_format/cli.py +++ b/icebug_format/cli.py @@ -38,7 +38,9 @@ ICEBUG_DISK_VERSION = "v1" -def _write_parquet_with_icebug_metadata(con, table_name: str, output_path: Path) -> None: +def _write_parquet_with_icebug_metadata( + con, table_name: str, output_path: Path +) -> None: """Export a DuckDB table to parquet with icebug_disk_version metadata.""" con.execute( f""" @@ -341,7 +343,7 @@ def get_display_name(table_name: str, prefix: str) -> str: if table_name == prefix: return prefix if table_name.startswith(f"{prefix}_"): - return table_name[len(prefix) + 1:].lower() + return table_name[len(prefix) + 1 :].lower() return table_name.lower() # Export node tables: nodes_.parquet @@ -355,7 +357,9 @@ def get_display_name(table_name: str, prefix: str) -> str: # Export edge tables: indices_.parquet, indptr_.parquet for edge_table in edge_tables: edge_name = ( - edge_table[6:].lower() if edge_table.startswith("edges_") else edge_table.lower() + edge_table[6:].lower() + if edge_table.startswith("edges_") + else edge_table.lower() ) indices_table = f"{csr_table_name}_indices_{edge_name}" indices_file = parquet_dir / f"indices_{edge_name}.parquet" @@ -396,7 +400,7 @@ def create_csr_graph_to_duckdb( source_db_path: str, output_db_path: str, limit_rels: int | None = None, - undirected: bool = False, + add_reverse_edges: bool = False, csr_table_name: str = "csr_graph", node_table: str | None = None, edge_table: str | None = None, @@ -411,7 +415,7 @@ def create_csr_graph_to_duckdb( source_db_path: Path to source DuckDB with edges table output_db_path: Path to output DuckDB for CSR data limit_rels: Limit number of relationships for testing - undirected: Whether graph is undirected + add_reverse_edges: Whether to add reverse edges for symmetric adjacency csr_table_name: Name of table to store CSR data node_table: Specific node table to use (default: auto-discover) edge_table: Specific edge table to use (default: auto-discover) @@ -501,14 +505,18 @@ def create_csr_graph_to_duckdb( for et in edge_tables: edge_name = et[6:].lower() if et.startswith("edges_") else et.lower() - src_node_type, dst_node_type = edge_relationships.get(edge_name, (None, None)) + src_node_type, dst_node_type = edge_relationships.get( + edge_name, (None, None) + ) src_table = node_type_to_table.get(src_node_type) dst_table = node_type_to_table.get(dst_node_type) if src_table and dst_table: num_src_nodes = node_counts.get(src_table, 0) - print(f"\n Processing {et}: {src_node_type} ({num_src_nodes} nodes) -> {dst_node_type}") + print( + f"\n Processing {et}: {src_node_type} ({num_src_nodes} nodes) -> {dst_node_type}" + ) else: src_table = dst_table = node_tables[0] if node_tables else "nodes" num_src_nodes = node_counts.get(src_table, 0) @@ -519,13 +527,13 @@ def create_csr_graph_to_duckdb( src_csr_table = f"{csr_table_name}_{src_table}" dst_csr_table = f"{csr_table_name}_{dst_table}" - # For undirected graphs, from and to node tables must be the same. - if undirected: + # Reverse-edge expansion requires one node mapping for both endpoints. + if add_reverse_edges: if src_table != dst_table: raise ValueError( - f"Undirected graphs require the same node table on both sides of an " + f"Adding reverse edges requires the same node table on both sides of an " f"edge, but edge table '{et}' connects '{src_table}' -> '{dst_table}'. " - f"Use --undirected for homogeneous edge tables." + f"Use --add-reverse-edges only for homogeneous edge tables." ) dst_pk = src_pk dst_csr_table = src_csr_table @@ -551,16 +559,17 @@ def create_csr_graph_to_duckdb( select_cols = "m1.csr_index AS csr_source, m2.csr_index AS csr_target" if edge_cols: select_cols += ", " + ", ".join([f"e.{c}" for c in edge_cols]) - reverse_select_cols = "m2.csr_index AS csr_source, m1.csr_index AS csr_target" + reverse_select_cols = ( + "m2.csr_index AS csr_source, m1.csr_index AS csr_target" + ) if edge_cols: reverse_select_cols += ", " + ", ".join([f"e.{c}" for c in edge_cols]) reverse_cols = "csr_target AS csr_source, csr_source AS csr_target" if edge_cols: reverse_cols += ", " + ", ".join(edge_cols) - # Self-loops are not filtered from directed graphs. - # For undirected graphs, the reverse UNION excludes self-loops so - # each self-loop appears exactly once (forward only). + # Self-loops are not filtered. Reverse-edge expansion excludes + # self-loops so each self-loop appears exactly once (forward only). join_clause = f""" FROM orig.{et} e JOIN src_map m1 ON e.source = m1.original_node_id @@ -568,14 +577,13 @@ def create_csr_graph_to_duckdb( if limit_rels: limit_per_table = limit_rels // len(edge_tables) - if not undirected: + if not add_reverse_edges: rel_query = f""" WITH {map_cte} SELECT {select_cols} {join_clause} LIMIT {limit_per_table} """ else: - # Reverse self-loops using CSR indices (already mapped) rel_query = f""" WITH {map_cte}, limited AS ( @@ -588,7 +596,7 @@ def create_csr_graph_to_duckdb( WHERE csr_source != csr_target """ else: - if not undirected: + if not add_reverse_edges: rel_query = f""" WITH {map_cte} SELECT {select_cols} {join_clause} @@ -741,9 +749,10 @@ def main(): help="Number of edges to use in test mode (default: 50000)", ) parser.add_argument( - "--undirected", + "--add-reverse-edges", + dest="add_reverse_edges", action="store_true", - help="Treat graph as undirected (default: directed)", + help="Add reverse edges for symmetric adjacency (default: preserve input direction)", ) parser.add_argument( "--storage", @@ -784,7 +793,7 @@ def main(): print(f"GraphAr directory: {args.graphar}") print(f"CSR output database: {args.output_db}") print(f"CSR table prefix: {args.csr_table}") - print(f"Undirected: {args.undirected}") + print(f"Add reverse edges: {args.add_reverse_edges}") print(f"DuckDB memory limit: {args.memory_limit}") try: @@ -799,7 +808,7 @@ def main(): graphar_dir=args.graphar, output_db_path=args.output_db, csr_table_name=args.csr_table, - undirected=args.undirected, + add_reverse_edges=args.add_reverse_edges, memory_limit=args.memory_limit, ) @@ -823,7 +832,7 @@ def main(): print(f"Source database: {source_db_path}") print(f"CSR output database: {args.output_db}") print(f"CSR table prefix: {args.csr_table}") - print(f"Undirected: {args.undirected}") + print(f"Add reverse edges: {args.add_reverse_edges}") print(f"DuckDB memory limit: {args.memory_limit}") # Compute default storage path from output_db if not specified @@ -843,7 +852,7 @@ def main(): source_db_path=source_db_path, output_db_path=args.output_db, limit_rels=test_limit, - undirected=args.undirected, + add_reverse_edges=args.add_reverse_edges, csr_table_name=args.csr_table, node_table=args.node_table, edge_table=args.edge_table, diff --git a/icebug_format/graphar.py b/icebug_format/graphar.py index c1ca904..bba30b2 100644 --- a/icebug_format/graphar.py +++ b/icebug_format/graphar.py @@ -23,7 +23,10 @@ import duckdb import pyarrow.parquet as pq -from icebug_format.cli import ICEBUG_DISK_VERSION, _write_parquet_with_icebug_metadata, set_memory_limit +from icebug_format.cli import ( + _write_parquet_with_icebug_metadata, + set_memory_limit, +) def duckdb_type_to_cypher_type(duckdb_type: str) -> str: @@ -157,7 +160,7 @@ def convert_graphar_to_graph_std( graphar_dir: str, output_db_path: str, csr_table_name: str = "graph", - undirected: bool = False, + add_reverse_edges: bool = False, memory_limit: str = "80%", ) -> None: """ @@ -167,7 +170,7 @@ def convert_graphar_to_graph_std( graphar_dir: Path to directory with GraphAr data output_db_path: Path to output DuckDB database csr_table_name: Name prefix for CSR tables - undirected: Whether graph is undirected + add_reverse_edges: Whether to add reverse edges for symmetric adjacency memory_limit: DuckDB memory limit setting """ print("\n=== Converting GraphAr to Graph-Std Format ===") @@ -208,24 +211,12 @@ def convert_graphar_to_graph_std( # Find property group directories and read data (directories start with _) vertex_rows = [] - prop_group_names = [] for item in vertex_dir.iterdir(): if item.is_dir() and item.name.startswith("_"): for chunk_file in sorted(item.iterdir()): if chunk_file.is_file(): table = pq.read_table(str(chunk_file)) - if not vertex_rows: - prop_group_names = [ - table.schema.field(j).name - for j in range(table.num_columns) - ] - { - table.schema.field(j).name: str( - table.schema.field(j).type - ) - for j in range(table.num_columns) - } for i in range(table.num_rows): row = { table.schema.field(j).name: table.column(j)[i].as_py() @@ -281,6 +272,13 @@ def convert_graphar_to_graph_std( print(f" Warning: Missing node tables for {src_type} -> {dst_type}") continue + if add_reverse_edges and src_type != dst_type: + raise ValueError( + f"Adding reverse edges requires the same node type on both sides of an " + f"edge, but edge type '{edge_type}' connects '{src_type}' -> '{dst_type}'. " + f"Use --add-reverse-edges only for homogeneous edge types." + ) + # Get vertex counts num_src_nodes = con.execute(f"SELECT COUNT(*) FROM {src_table}").fetchone()[0] print(f" Source nodes: {num_src_nodes}") @@ -347,6 +345,13 @@ def convert_graphar_to_graph_std( con.execute(f"CREATE TABLE {rel_table_name} ({col_defs})") + def insert_relation(values): + placeholders = ", ".join(["?"] * len(values)) + con.execute( + f"INSERT INTO {rel_table_name} VALUES ({placeholders})", + values, + ) + # Insert edges with properties for i, (src_idx, dst_idx) in enumerate(edges_list): values = [src_idx, dst_idx] @@ -360,17 +365,23 @@ def convert_graphar_to_graph_std( ) values.extend(prop_vals) - con.execute( - f"INSERT INTO {rel_table_name} VALUES ({', '.join(str(v) for v in values)})" - ) + insert_relation(values) + if add_reverse_edges and src_idx != dst_idx: + insert_relation([dst_idx, src_idx, *values[2:]]) else: con.execute( f"CREATE TABLE {rel_table_name} (csr_source BIGINT, csr_target BIGINT)" ) for src_idx, dst_idx in edges_list: con.execute( - f"INSERT INTO {rel_table_name} VALUES ({src_idx}, {dst_idx})" + f"INSERT INTO {rel_table_name} VALUES (?, ?)", + [src_idx, dst_idx], ) + if add_reverse_edges and src_idx != dst_idx: + con.execute( + f"INSERT INTO {rel_table_name} VALUES (?, ?)", + [dst_idx, src_idx], + ) # Build CSR indptr indptr_table = f"{csr_table_name}_indptr_{edge_type}" @@ -400,7 +411,9 @@ def convert_graphar_to_graph_std( con.execute(f"DROP TABLE IF EXISTS {temp_table}") con.execute(f"CREATE TABLE {temp_table} (ptr UBIGINT)") con.execute(f"INSERT INTO {temp_table} VALUES (CAST(0 AS UBIGINT))") - con.execute(f"INSERT INTO {temp_table} SELECT CAST(ptr AS UBIGINT) FROM {indptr_table}") + con.execute( + f"INSERT INTO {temp_table} SELECT CAST(ptr AS UBIGINT) FROM {indptr_table}" + ) con.execute(f"DROP TABLE {indptr_table}") con.execute(f"ALTER TABLE {temp_table} RENAME TO {indptr_table}") @@ -569,9 +582,10 @@ def main(): help="Table name prefix for CSR data (default: graph)", ) parser.add_argument( - "--undirected", + "--add-reverse-edges", + dest="add_reverse_edges", action="store_true", - help="Treat graph as undirected (default: directed)", + help="Add reverse edges for symmetric adjacency (default: preserve input direction)", ) args = parser.parse_args() @@ -580,13 +594,13 @@ def main(): print(f"GraphAr directory: {args.graphar_dir}") print(f"CSR output database: {args.output_db}") print(f"CSR table prefix: {args.csr_table}") - print(f"Undirected: {args.undirected}") + print(f"Add reverse edges: {args.add_reverse_edges}") convert_graphar_to_graph_std( graphar_dir=args.graphar_dir, output_db_path=args.output_db, csr_table_name=args.csr_table, - undirected=args.undirected, + add_reverse_edges=args.add_reverse_edges, ) print("\n=== Conversion Completed Successfully! ===") diff --git a/icebug_format/memory.py b/icebug_format/memory.py index e561171..5514e47 100644 --- a/icebug_format/memory.py +++ b/icebug_format/memory.py @@ -60,7 +60,7 @@ def from_arrow_tables( rel_arrow_table: pa.Table, *, to_node_arrow_table: pa.Table | None = None, - undirected: bool = False, + add_reverse_edges: bool = False, ) -> "IcebugMemGraph": """ Convert node and relationship Arrow tables to an IcebugMemGraph. @@ -77,10 +77,8 @@ def from_arrow_tables( Any remaining columns in *rel_arrow_table* are preserved as edge properties in the *indices* output table. - For undirected graphs (``undirected=True``), ``to_node_arrow_table`` must - not be provided: the from-node table is used for both sides of every - edge. Providing ``to_node_arrow_table`` while also passing - ``undirected=True`` raises ``ValueError``. + When ``add_reverse_edges=True``, ``to_node_arrow_table`` must not be + provided: the from-node table is used for both sides of every edge. Args: from_node_arrow_table: Source node table. @@ -88,9 +86,9 @@ def from_arrow_tables( to_node_arrow_table: Destination node table (directed graphs only). Defaults to *from_node_arrow_table* when ``None`` (i.e., homogeneous edges). - undirected: If ``False`` (default), only forward edges are + add_reverse_edges: If ``False`` (default), only input edges are stored. If ``True``, reverse edges are added - so the graph is treated as undirected. + for symmetric adjacency. Returns: IcebugMemGraph where *src* and *dest* are the original node tables @@ -98,14 +96,13 @@ def from_arrow_tables( Raises: ValueError: If *rel_arrow_table* has fewer than 2 columns. - ValueError: If ``undirected=True`` and *to_node_arrow_table* is - provided (undirected graphs always use a single node - table for both sides). + ValueError: If ``add_reverse_edges=True`` and *to_node_arrow_table* + is provided. """ - if undirected and to_node_arrow_table is not None: + if add_reverse_edges and to_node_arrow_table is not None: raise ValueError( - "to_node_arrow_table must not be provided for undirected graphs; " - "from and to node tables are always the same for undirected edges." + "to_node_arrow_table must not be provided when adding reverse edges; " + "from and to node tables must be the same." ) if to_node_arrow_table is None: @@ -128,6 +125,7 @@ def from_arrow_tables( select_fwd = "m1.csr_index AS csr_source, m2.csr_index AS csr_target" select_rev = "m2.csr_index AS csr_source, m1.csr_index AS csr_target" + def q(name: str) -> str: return '"' + name.replace('"', '""') + '"' @@ -155,7 +153,7 @@ def q(name: str) -> str: JOIN dst_map m2 ON e.{q(dst_col)} = m2.original_node_id """ - if not undirected: + if not add_reverse_edges: rel_query = f"WITH {map_cte} SELECT {select_fwd} {join_clause}" else: # Self-loops appear once (forward only); non-self edges get both directions. @@ -167,7 +165,9 @@ def q(name: str) -> str: WHERE e.{q(src_col)} != e.{q(dst_col)} """ - edge_props_select = (", " + ", ".join(q(c) for c in edge_cols)) if edge_cols else "" + edge_props_select = ( + (", " + ", ".join(q(c) for c in edge_cols)) if edge_cols else "" + ) con = duckdb.connect() try: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0a44725..d9e3792 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -83,7 +83,7 @@ def test_directed_basic(): out = str(Path(tmpdir) / "out.duckdb") # 0 -> 1 -> 2 _make_source_db(src, [(0, 1), (1, 2)]) - create_csr_graph_to_duckdb(src, out, undirected=False, memory_limit=_MEM) + create_csr_graph_to_duckdb(src, out, add_reverse_edges=False, memory_limit=_MEM) con = duckdb.connect(out) indices = con.execute( @@ -92,10 +92,16 @@ def test_directed_basic(): indptr = con.execute( "SELECT ptr FROM csr_graph_indptr_edges ORDER BY rowid" ).fetchall() + metadata = con.execute( + "SELECT CAST(value AS VARCHAR) FROM parquet_kv_metadata(?) " + "WHERE key = 'icebug_disk_version'", + [str(_parquet_dir(out) / "indices_edges.parquet")], + ).fetchone() con.close() assert [r[0] for r in indices] == [1, 2] assert [r[0] for r in indptr] == [0, 1, 2, 2] + assert metadata == ("v1",) def test_csr_columns_are_uint64(): @@ -103,7 +109,7 @@ def test_csr_columns_are_uint64(): src = str(Path(tmpdir) / "src.duckdb") out = str(Path(tmpdir) / "out.duckdb") _make_source_db(src, [(0, 1), (1, 2)]) - create_csr_graph_to_duckdb(src, out, undirected=False, memory_limit=_MEM) + create_csr_graph_to_duckdb(src, out, add_reverse_edges=False, memory_limit=_MEM) con = duckdb.connect(out) indices_desc = con.execute("DESCRIBE csr_graph_indices_edges").fetchall() @@ -123,7 +129,7 @@ def test_directed_preserves_self_loops(): out = str(Path(tmpdir) / "out.duckdb") # 0->0 (self-loop) + 0->1 _make_source_db(src, [(0, 0), (0, 1)]) - create_csr_graph_to_duckdb(src, out, undirected=False, memory_limit=_MEM) + create_csr_graph_to_duckdb(src, out, add_reverse_edges=False, memory_limit=_MEM) con = duckdb.connect(out) indices = con.execute( @@ -135,17 +141,17 @@ def test_directed_preserves_self_loops(): # --------------------------------------------------------------------------- -# Undirected graph +# Reverse-edge expansion # --------------------------------------------------------------------------- -def test_undirected_adds_reverse_edges(): +def test_add_reverse_edges_adds_reverse_edges(): with tempfile.TemporaryDirectory() as tmpdir: src = str(Path(tmpdir) / "src.duckdb") out = str(Path(tmpdir) / "out.duckdb") # 0 -- 1 _make_source_db(src, [(0, 1)]) - create_csr_graph_to_duckdb(src, out, undirected=True, memory_limit=_MEM) + create_csr_graph_to_duckdb(src, out, add_reverse_edges=True, memory_limit=_MEM) con = duckdb.connect(out) count = con.execute("SELECT COUNT(*) FROM csr_graph_indices_edges").fetchone()[ @@ -156,14 +162,14 @@ def test_undirected_adds_reverse_edges(): assert count == 2 # forward + reverse -def test_undirected_self_loop_appears_once(): - """Self-loops in an undirected graph must appear exactly once.""" +def test_add_reverse_edges_self_loop_appears_once(): + """Self-loops must appear exactly once when reverse edges are added.""" with tempfile.TemporaryDirectory() as tmpdir: src = str(Path(tmpdir) / "src.duckdb") out = str(Path(tmpdir) / "out.duckdb") # 0->0 self-loop + 0->1 _make_source_db(src, [(0, 0), (0, 1)]) - create_csr_graph_to_duckdb(src, out, undirected=True, memory_limit=_MEM) + create_csr_graph_to_duckdb(src, out, add_reverse_edges=True, memory_limit=_MEM) con = duckdb.connect(out) count = con.execute("SELECT COUNT(*) FROM csr_graph_indices_edges").fetchone()[ @@ -176,12 +182,12 @@ def test_undirected_self_loop_appears_once(): # --------------------------------------------------------------------------- -# Undirected validation +# Reverse-edge validation # --------------------------------------------------------------------------- -def test_undirected_heterogeneous_edges_raise(): - """Undirected graphs must not have heterogeneous (bipartite) edge tables.""" +def test_add_reverse_edges_heterogeneous_edges_raise(): + """Reverse-edge expansion requires homogeneous edge tables.""" with tempfile.TemporaryDirectory() as tmpdir: src = str(Path(tmpdir) / "src.duckdb") out = str(Path(tmpdir) / "out.duckdb") @@ -196,7 +202,7 @@ def test_undirected_heterogeneous_edges_raise(): create_csr_graph_to_duckdb( src, out, - undirected=True, + add_reverse_edges=True, schema_path=str(schema_path), memory_limit=_MEM, ) @@ -215,7 +221,7 @@ def test_limit_rels_caps_edge_count(): # 10 edges: 0->1, 1->2, ..., 9->10 _make_source_db(src, [(i, i + 1) for i in range(10)]) create_csr_graph_to_duckdb( - src, out, undirected=False, limit_rels=3, memory_limit=_MEM + src, out, add_reverse_edges=False, limit_rels=3, memory_limit=_MEM ) con = duckdb.connect(out) @@ -227,15 +233,15 @@ def test_limit_rels_caps_edge_count(): assert count <= 3 -def test_limit_rels_undirected_adds_reverse_within_limit(): - """For undirected graphs, limit_rels applies to forward edges; reverse are added after.""" +def test_limit_rels_add_reverse_edges_adds_reverse_within_limit(): + """limit_rels applies before reverse edges are added.""" with tempfile.TemporaryDirectory() as tmpdir: src = str(Path(tmpdir) / "src.duckdb") out = str(Path(tmpdir) / "out.duckdb") # 6 distinct edges: 0-1, 1-2, 2-3, 3-4, 4-5, 5-0 _make_source_db(src, [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0)]) create_csr_graph_to_duckdb( - src, out, undirected=True, limit_rels=2, memory_limit=_MEM + src, out, add_reverse_edges=True, limit_rels=2, memory_limit=_MEM ) con = duckdb.connect(out) @@ -260,7 +266,11 @@ def test_csr_table_name_prefix(): out = str(Path(tmpdir) / "out.duckdb") _make_source_db(src, [(0, 1), (1, 2)]) create_csr_graph_to_duckdb( - src, out, undirected=False, csr_table_name="mygraph", memory_limit=_MEM + src, + out, + add_reverse_edges=False, + csr_table_name="mygraph", + memory_limit=_MEM, ) con = duckdb.connect(out) @@ -286,7 +296,11 @@ def test_node_table_selects_single_table(): out = str(Path(tmpdir) / "out.duckdb") _make_multi_node_source_db(src) create_csr_graph_to_duckdb( - src, out, undirected=False, node_table="nodes_user", memory_limit=_MEM + src, + out, + add_reverse_edges=False, + node_table="nodes_user", + memory_limit=_MEM, ) con = duckdb.connect(out) @@ -304,7 +318,11 @@ def test_edge_table_selects_single_table(): out = str(Path(tmpdir) / "out.duckdb") _make_multi_edge_source_db(src) create_csr_graph_to_duckdb( - src, out, undirected=False, edge_table="edges_follows", memory_limit=_MEM + src, + out, + add_reverse_edges=False, + edge_table="edges_follows", + memory_limit=_MEM, ) con = duckdb.connect(out) @@ -325,7 +343,7 @@ def test_edge_table_not_found_raises(): create_csr_graph_to_duckdb( src, out, - undirected=False, + add_reverse_edges=False, edge_table="edges_nonexistent", memory_limit=_MEM, ) @@ -349,7 +367,11 @@ def test_schema_path_maps_from_to_node_types(): ) create_csr_graph_to_duckdb( - src, out, undirected=False, schema_path=str(schema_path), memory_limit=_MEM + src, + out, + add_reverse_edges=False, + schema_path=str(schema_path), + memory_limit=_MEM, ) out_schema = (_parquet_dir(out) / "schema.cypher").read_text() @@ -371,7 +393,7 @@ def test_storage_path_appears_in_schema_cypher(): create_csr_graph_to_duckdb( src, out, - undirected=False, + add_reverse_edges=False, storage_path="./my_custom_store", memory_limit=_MEM, ) @@ -387,7 +409,7 @@ def test_storage_path_default_uses_output_stem(): out = str(Path(tmpdir) / "out.duckdb") _make_source_db(src, [(0, 1)]) - create_csr_graph_to_duckdb(src, out, undirected=False, memory_limit=_MEM) + create_csr_graph_to_duckdb(src, out, add_reverse_edges=False, memory_limit=_MEM) out_schema = (_parquet_dir(out) / "schema.cypher").read_text() # Default storage_path is "./out" (stem of out.duckdb) diff --git a/tests/test_memory.py b/tests/test_memory.py index 1b7122d..a0a0fb8 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -115,26 +115,26 @@ def test_directed_preserves_self_loops(): # --------------------------------------------------------------------------- -# Undirected graph +# Reverse-edge expansion # --------------------------------------------------------------------------- -def test_undirected_adds_reverse_edges(): +def test_add_reverse_edges_adds_reverse_edges(): # Graph: 0 -- 1 nodes = _nodes(0, 1) rels = _rels([0], [1]) - g = IcebugMemGraph.from_arrow_tables(nodes, rels, undirected=True) + g = IcebugMemGraph.from_arrow_tables(nodes, rels, add_reverse_edges=True) assert len(g.indices) == 2 targets = sorted(g.indices["target"].to_pylist()) assert targets == [0, 1] -def test_undirected_indptr_reflects_bidirectional_degree(): +def test_add_reverse_edges_indptr_reflects_bidirectional_degree(): # 0 -- 1 -- 2 nodes = _nodes(0, 1, 2) rels = _rels([0, 1], [1, 2]) - g = IcebugMemGraph.from_arrow_tables(nodes, rels, undirected=True) + g = IcebugMemGraph.from_arrow_tables(nodes, rels, add_reverse_edges=True) ptr = g.indptr["ptr"].to_pylist() # node 0: 1 neighbour, node 1: 2 neighbours, node 2: 1 neighbour @@ -146,10 +146,11 @@ def test_undirected_indptr_reflects_bidirectional_degree(): # Self-loop handling # --------------------------------------------------------------------------- -def test_self_loops_appear_once_in_undirected_graph(): + +def test_self_loops_appear_once_with_reverse_edges(): nodes = _nodes(0, 1) rels = _rels([0, 0], [0, 1]) # 0->0 self-loop + 0->1 - g = IcebugMemGraph.from_arrow_tables(nodes, rels, undirected=True) + g = IcebugMemGraph.from_arrow_tables(nodes, rels, add_reverse_edges=True) # 0->0 (once), 0->1, 1->0 → 3 entries total assert len(g.indices) == 3 @@ -222,8 +223,12 @@ def test_target_column_aliases(dst_col): def test_src_and_dest_tables_are_passed_through(): - src_nodes = pa.table({"id": pa.array([10, 20], type=pa.int64()), "label": pa.array(["a", "b"])}) - dst_nodes = pa.table({"id": pa.array([10, 20], type=pa.int64()), "label": pa.array(["c", "d"])}) + src_nodes = pa.table( + {"id": pa.array([10, 20], type=pa.int64()), "label": pa.array(["a", "b"])} + ) + dst_nodes = pa.table( + {"id": pa.array([10, 20], type=pa.int64()), "label": pa.array(["c", "d"])} + ) rels = _rels([10], [20]) g = IcebugMemGraph.from_arrow_tables(src_nodes, rels, to_node_arrow_table=dst_nodes) assert g.src.equals(src_nodes) @@ -250,19 +255,23 @@ def test_rel_table_with_fewer_than_two_columns_raises(): IcebugMemGraph.from_arrow_tables(nodes, bad_rels) -def test_undirected_with_to_node_table_raises(): +def test_add_reverse_edges_with_to_node_table_raises(): nodes = _nodes(0, 1) rels = _rels([0], [1]) with pytest.raises(ValueError, match="to_node_arrow_table must not be provided"): - IcebugMemGraph.from_arrow_tables(nodes, rels, to_node_arrow_table=nodes, undirected=True) + IcebugMemGraph.from_arrow_tables( + nodes, rels, to_node_arrow_table=nodes, add_reverse_edges=True + ) def test_column_names_with_spaces_are_handled(): nodes = pa.table({"node id": pa.array([0, 1], type=pa.int64())}) - rels = pa.table({ - "source": pa.array([0], type=pa.int64()), - "destination": pa.array([1], type=pa.int64()), - }) + rels = pa.table( + { + "source": pa.array([0], type=pa.int64()), + "destination": pa.array([1], type=pa.int64()), + } + ) g = IcebugMemGraph.from_arrow_tables(nodes, rels) assert len(g.indices) == 1